feat: 订单指引流程图、对客说明与查价/菜单体验优化

新增订单与渠道新手指引(Mermaid 流程图),销售产品对客说明 Tab,查价表格与详情抽屉,菜单图标可视化选择;MarkdownViewer 支持 Mermaid 渲染。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-09 15:17:33 +08:00
parent 6a7f8f1bcb
commit 1a5e799ce2
30 changed files with 3148 additions and 154 deletions

View File

@ -14,6 +14,7 @@
"MaybeRefOrGetter": true,
"PropType": true,
"Ref": true,
"ScanFeedbackType": true,
"Slot": true,
"Slots": true,
"VNode": true,
@ -226,7 +227,9 @@
"useObjectUrl": true,
"useOffsetPagination": true,
"useOnline": true,
"useOperationScanFeedback": true,
"useOperationScanFocus": true,
"useOperationVoiceBroadcast": true,
"usePageLeave": true,
"useParallax": true,
"usePermission": true,

View File

@ -41,12 +41,14 @@ server {
location ^~ /operation/printLabel { try_files /index.html =200; }
# 订单前端路由(^~ 须比下方 /order/ API 正则代理更优先)
location ^~ /order/guide { try_files /index.html =200; }
location ^~ /order/priceCheck { try_files /index.html =200; }
location ^~ /order/placeOrder { try_files /index.html =200; }
location ^~ /order/myOrders { try_files /index.html =200; }
location ^~ /order/batchJobs { try_files /index.html =200; }
location ^~ /order/templateMapping { try_files /index.html =200; }
location ^~ /channel/guide { try_files /index.html =200; }
location ^~ /channel/salesProducts { try_files /index.html =200; }
location ^~ /channel/serviceChannels { try_files /index.html =200; }
location ^~ /channel/priceArea { try_files /index.html =200; }

1033
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -64,6 +64,7 @@
"json5": "^2.2.3",
"lodash-es": "^4.17.21",
"marked": "^18.0.4",
"mermaid": "^11.16.0",
"mitt": "^3.0.0",
"nprogress": "^0.2.0",
"pinia": "^2.0.23",

View File

@ -67,6 +67,15 @@ export async function deleteSalesProduct(id: number | string) {
assertBusinessOk(res.data)
}
/** 对客说明 */
export async function saveSalesProductCustomerGuide(body: {
salesProductId: number | string
customerGuideMarkdown?: string
}) {
const res = await axiosInstance.post(omsUrl('/channel/sales-product/customer-guide/save'), body)
assertBusinessOk(res.data)
}
/** 卖价 */
export async function getSalesProductPriceList(salesProductId: number | string) {
const res = await axiosInstance.get(omsUrl('/channel/sales-product/price/list'), {

View File

@ -215,7 +215,9 @@ declare global {
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
const useOnline: typeof import('@vueuse/core')['useOnline']
const useOperationScanFeedback: typeof import('./composables/useOperationScanFeedback')['useOperationScanFeedback']
const useOperationScanFocus: typeof import('./composables/useOperationScanFocus')['useOperationScanFocus']
const useOperationVoiceBroadcast: typeof import('./composables/useOperationVoiceBroadcast')['useOperationVoiceBroadcast']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const useParallax: typeof import('@vueuse/core')['useParallax']
const usePermission: typeof import('@vueuse/core')['usePermission']

2
src/components.d.ts vendored
View File

@ -29,8 +29,10 @@ declare module 'vue' {
JsonDiffPro: typeof import('./components/JsonDiffPro/index.vue')['default']
JsonEditorPro: typeof import('./components/JsonEditorPro/index.vue')['default']
List: typeof import('./components/message-box/list.vue')['default']
MarkdownDocPanel: typeof import('./components/MarkdownDocPanel/index.vue')['default']
MarkdownViewer: typeof import('./components/MarkdownViewer/index.vue')['default']
Menu: typeof import('./components/menu/index.vue')['default']
MenuIconPicker: typeof import('./components/MenuIconPicker/index.vue')['default']
Message: typeof import('./components/navbar/Message.vue')['default']
MessageBox: typeof import('./components/message-box/index.vue')['default']
ModalForm: typeof import('./components/ModalForm/index.vue')['default']

View File

@ -0,0 +1,261 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import MarkdownViewer from '@/components/MarkdownViewer/index.vue';
import { useI18n } from 'vue-i18n';
defineOptions({ name: 'MarkdownDocPanel' });
const props = withDefaults(
defineProps<{
content: string;
loading?: boolean;
saving?: boolean;
emptyHint?: string;
templateContent?: string;
}>(),
{
loading: false,
saving: false,
emptyHint: '',
templateContent: '',
},
);
const emit = defineEmits<{
save: [content: string];
}>();
const { t } = useI18n();
const editing = ref(false);
const draft = ref('');
const displayContent = computed(() => props.content?.trim() || '');
const isEmpty = computed(() => !displayContent.value && !editing.value);
watch(
() => props.content,
(val) => {
if (!editing.value) {
draft.value = val || '';
}
},
{ immediate: true },
);
function handleEdit() {
draft.value = props.content || '';
editing.value = true;
}
function handleCancel() {
draft.value = props.content || '';
editing.value = false;
}
function handleSave() {
emit('save', draft.value);
}
function handleApplyTemplate() {
if (!props.templateContent?.trim()) return;
draft.value = props.templateContent;
if (!editing.value) {
editing.value = true;
}
}
function handleClear() {
draft.value = '';
if (!editing.value) {
editing.value = true;
}
}
defineExpose({
stopEditing: () => {
editing.value = false;
},
});
</script>
<template>
<div class="markdown-doc-panel">
<div class="markdown-doc-toolbar">
<div class="toolbar-left">
<span v-if="editing" class="edit-badge">{{ t('channel.salesProducts.customerGuide.editing') }}</span>
</div>
<div class="toolbar-right">
<template v-if="editing">
<a-button size="small" :disabled="saving" @click="handleCancel">
{{ t('channel.salesProducts.customerGuide.cancel') }}
</a-button>
<a-button
v-if="templateContent"
size="small"
type="secondary"
:disabled="saving"
@click="handleApplyTemplate"
>
{{ t('channel.salesProducts.customerGuide.useTemplate') }}
</a-button>
<a-button size="small" type="secondary" status="danger" :disabled="saving" @click="handleClear">
{{ t('channel.salesProducts.customerGuide.clear') }}
</a-button>
<a-button size="small" type="primary" :loading="saving" @click="handleSave">
{{ t('channel.salesProducts.actions.save') }}
</a-button>
</template>
<template v-else>
<a-button
v-if="templateContent && isEmpty"
size="small"
type="secondary"
:disabled="loading"
@click="handleApplyTemplate"
>
{{ t('channel.salesProducts.customerGuide.useTemplate') }}
</a-button>
<a-button size="small" type="primary" :disabled="loading" @click="handleEdit">
{{ t('channel.salesProducts.actions.edit') }}
</a-button>
</template>
</div>
</div>
<a-spin :loading="loading" class="markdown-doc-body">
<div v-if="editing" class="markdown-doc-edit">
<div class="markdown-doc-editor">
<div class="pane-title">{{ t('channel.salesProducts.customerGuide.source') }}</div>
<a-textarea
v-model="draft"
:auto-size="false"
class="markdown-source-input"
:placeholder="t('channel.salesProducts.customerGuide.placeholder')"
/>
</div>
<div class="markdown-doc-preview">
<div class="pane-title">{{ t('channel.salesProducts.customerGuide.preview') }}</div>
<div class="preview-pane">
<MarkdownViewer v-if="draft.trim()" :content="draft" />
<div v-else class="preview-empty">{{ t('channel.salesProducts.customerGuide.previewEmpty') }}</div>
</div>
</div>
</div>
<div v-else class="markdown-doc-view">
<MarkdownViewer v-if="displayContent" :content="displayContent" />
<a-empty v-else :description="emptyHint || t('channel.salesProducts.customerGuide.empty')" />
</div>
</a-spin>
</div>
</template>
<style lang="less" scoped>
.markdown-doc-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.markdown-doc-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.edit-badge {
font-size: 13px;
color: var(--app-primary-color);
}
.markdown-doc-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.markdown-doc-view {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 24px 24px;
}
.markdown-doc-edit {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
overflow: hidden;
}
.markdown-doc-editor,
.markdown-doc-preview {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
border-right: 1px solid #e8e8e8;
&:last-child {
border-right: none;
}
}
.pane-title {
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
color: #4e5969;
background: #f7f8fa;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0;
}
.markdown-source-input {
flex: 1;
min-height: 0;
height: 100%;
border: none;
border-radius: 0;
resize: none;
font-family: Consolas, Monaco, 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
:deep(textarea) {
height: 100% !important;
min-height: 100% !important;
padding: 16px;
box-sizing: border-box;
}
}
.preview-pane {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
}
.preview-empty {
color: #86909c;
font-size: 13px;
padding: 8px 4px;
}
</style>

View File

@ -1,6 +1,7 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { marked } from 'marked';
import mermaid from 'mermaid';
defineOptions({ name: 'MarkdownViewer' });
@ -8,19 +9,68 @@
content: string;
}>();
const containerRef = ref<HTMLElement | null>(null);
marked.setOptions({
gfm: true,
breaks: true,
});
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: 'basis',
},
});
function unescapeHtml(text: string) {
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
const html = computed(() => {
if (!props.content?.trim()) return '';
return marked.parse(props.content) as string;
const parsed = marked.parse(props.content) as string;
return parsed.replace(
/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
(_, code) => `<div class="mermaid">${unescapeHtml(code)}</div>`,
);
});
async function renderMermaid() {
await nextTick();
const root = containerRef.value;
if (!root) return;
const nodes = root.querySelectorAll<HTMLElement>('.mermaid');
if (nodes.length === 0) return;
try {
await mermaid.run({ nodes: Array.from(nodes) });
} catch (error) {
console.warn('[MarkdownViewer] mermaid render failed', error);
}
}
watch(html, () => {
renderMermaid();
});
onMounted(() => {
renderMermaid();
});
</script>
<template>
<article class="markdown-viewer" v-html="html" />
<article ref="containerRef" class="markdown-viewer" v-html="html" />
</template>
<style lang="less" scoped>
@ -106,6 +156,16 @@
}
}
:deep(.mermaid) {
margin: 16px 0 20px;
padding: 16px 12px;
overflow-x: auto;
background: #fafbfc;
border: 1px solid #e5e6eb;
border-radius: 8px;
text-align: center;
}
:deep(table) {
width: 100%;
margin: 12px 0 16px;

View File

@ -0,0 +1,325 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { ARCO_ICON_NAMES, filterArcoIconNames } from '@/utils/arco-icon-list';
import { resolveArcoIcon, toPascalIconName } from '@/utils/menu-icon';
defineOptions({ name: 'MenuIconPicker' });
const props = withDefaults(
defineProps<{
modelValue?: string;
placeholder?: string;
disabled?: boolean;
}>(),
{
modelValue: '',
placeholder: '',
disabled: false,
},
);
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const { t } = useI18n();
const pickerVisible = ref(false);
const keyword = ref('');
const draftIcon = ref('');
const normalizedValue = computed(() => toPascalIconName(props.modelValue || ''));
const previewIcon = computed(() => resolveArcoIcon(normalizedValue.value));
const filteredIcons = computed(() => {
const list = filterArcoIconNames(keyword.value)
if (keyword.value.trim()) return list
const commonSet = new Set(commonIcons)
return list.filter((name) => !commonSet.has(name))
});
const commonIcons = [
'IconHome',
'IconMenu',
'IconApps',
'IconBook',
'IconFile',
'IconFolder',
'IconList',
'IconOrderedList',
'IconSettings',
'IconUser',
'IconUserGroup',
'IconSearch',
'IconPlus',
'IconEdit',
'IconDelete',
'IconRefresh',
'IconLink',
'IconSend',
'IconDownload',
'IconUpload',
'IconCloud',
'IconComputer',
'IconStorage',
'IconTag',
'IconLocation',
'IconBarChart',
'IconSchedule',
'IconSafe',
'IconLock',
'IconPrinter',
'IconBug',
'IconCode',
'IconExperiment',
'IconThunderbolt',
'IconRobot',
'IconDashboard',
].filter((name) => ARCO_ICON_NAMES.includes(name));
watch(pickerVisible, (visible) => {
if (visible) {
draftIcon.value = normalizedValue.value;
keyword.value = '';
}
});
function openPicker() {
if (props.disabled) return;
pickerVisible.value = true;
}
function selectIcon(name: string) {
draftIcon.value = name;
emit('update:modelValue', name);
pickerVisible.value = false;
}
function handleInputBlur() {
const next = toPascalIconName(props.modelValue || '');
if (next !== props.modelValue) {
emit('update:modelValue', next);
}
}
function clearIcon() {
emit('update:modelValue', '');
}
</script>
<template>
<div class="menu-icon-picker">
<div class="menu-icon-picker-input">
<span class="icon-preview" :class="{ empty: !previewIcon }">
<component :is="previewIcon" v-if="previewIcon" />
<span v-else class="icon-preview-placeholder">—</span>
</span>
<a-input
:model-value="modelValue"
:placeholder="placeholder || t('system.menu.iconPicker.placeholder')"
:disabled="disabled"
allow-clear
@update:model-value="emit('update:modelValue', $event)"
@blur="handleInputBlur"
@clear="clearIcon"
/>
<a-button type="secondary" :disabled="disabled" @click="openPicker">
{{ t('system.menu.iconPicker.choose') }}
</a-button>
</div>
<a-modal
v-model:visible="pickerVisible"
:title="t('system.menu.iconPicker.title')"
:footer="false"
width="720px"
unmount-on-close
class="menu-icon-picker-modal"
>
<div class="picker-toolbar">
<a-input-search
v-model="keyword"
:placeholder="t('system.menu.iconPicker.searchPlaceholder')"
allow-clear
/>
<div v-if="draftIcon" class="picker-current">
<span>{{ t('system.menu.iconPicker.current') }}</span>
<component :is="resolveArcoIcon(draftIcon)" v-if="resolveArcoIcon(draftIcon)" />
<code>{{ draftIcon }}</code>
</div>
</div>
<div v-if="!keyword.trim()" class="picker-section">
<div class="picker-section-title">{{ t('system.menu.iconPicker.common') }}</div>
<div class="icon-grid">
<button
v-for="name in commonIcons"
:key="`common-${name}`"
type="button"
class="icon-item"
:class="{ active: draftIcon === name }"
:title="name"
@click="selectIcon(name)"
>
<component :is="resolveArcoIcon(name)" />
<span class="icon-name">{{ name.replace(/^Icon/, '') }}</span>
</button>
</div>
</div>
<div class="picker-section picker-section-scroll">
<div class="picker-section-title">
{{ keyword.trim() ? t('system.menu.iconPicker.searchResult') : t('system.menu.iconPicker.all') }}
<span class="picker-count">({{ filteredIcons.length }})</span>
</div>
<div class="icon-grid">
<button
v-for="name in filteredIcons"
:key="name"
type="button"
class="icon-item"
:class="{ active: draftIcon === name }"
:title="name"
@click="selectIcon(name)"
>
<component :is="resolveArcoIcon(name)" />
<span class="icon-name">{{ name.replace(/^Icon/, '') }}</span>
</button>
</div>
<a-empty
v-if="filteredIcons.length === 0"
:description="t('system.menu.iconPicker.empty')"
/>
</div>
</a-modal>
</div>
</template>
<style lang="less" scoped>
.menu-icon-picker-input {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
:deep(.arco-input-wrapper) {
flex: 1;
min-width: 0;
}
}
.icon-preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-fill-1);
color: var(--color-text-1);
flex-shrink: 0;
font-size: 18px;
&.empty {
color: var(--color-text-4);
}
}
.icon-preview-placeholder {
font-size: 14px;
line-height: 1;
}
.picker-toolbar {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.picker-current {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--color-text-2);
code {
padding: 2px 6px;
background: var(--color-fill-2);
border-radius: 4px;
font-size: 12px;
}
}
.picker-section {
& + .picker-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--color-border-2);
}
}
.picker-section-scroll {
max-height: 420px;
overflow: auto;
}
.picker-section-title {
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-2);
}
.picker-count {
font-weight: 400;
color: var(--color-text-3);
}
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: 8px;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 72px;
padding: 8px 4px;
border: 1px solid var(--color-border-2);
border-radius: 6px;
background: #fff;
cursor: pointer;
transition: all 0.2s ease;
color: var(--color-text-1);
font-size: 22px;
&:hover {
border-color: var(--app-primary-color);
background: var(--app-primary-color-light, #f0f9f9);
color: var(--app-primary-color);
}
&.active {
border-color: var(--app-primary-color);
background: var(--app-primary-color-light, #f0f9f9);
box-shadow: inset 0 0 0 1px var(--app-primary-color);
}
}
.icon-name {
max-width: 100%;
font-size: 11px;
line-height: 1.2;
text-align: center;
color: var(--color-text-3);
word-break: break-all;
}
</style>

View File

@ -1,9 +1,15 @@
<template>
<Teleport to="body">
<div
v-show="visible"
class="nana-backdrop"
@mousedown="closeChat"
/>
<div
v-show="visible"
class="nana-panel"
:style="panelStyle"
@mousedown.stop
>
<a-tabs v-model:active-key="activeTab" class="nana-panel__tabs" @change="onTabChange">
<a-tab-pane key="ai" :title="t('nanaAssistant.tabAi')">
@ -412,6 +418,13 @@ onUnmounted(() => {
</script>
<style scoped lang="less">
.nana-backdrop {
position: fixed;
inset: 0;
z-index: 999;
background: transparent;
}
.nana-fab-wrap {
position: fixed;
z-index: 1001;

View File

@ -77,12 +77,14 @@ export default {
'navbar.docs': 'Docs',
'navbar.action.locale': 'Switch to English',
'menu.order': 'Order',
'menu.order.orderGuide': 'Getting Started',
'menu.order.priceCheck': 'Price Check',
'menu.order.placeOrder': 'Place Order',
'menu.order.templateMapping': 'Template Mapping',
'menu.order.myOrders': 'My Orders',
'menu.order.batchJobs': 'Batch Jobs',
'menu.channel': 'Channel',
'menu.channel.channelGuide': 'Getting Started',
'menu.channel.salesProducts': 'Sales Products',
'menu.channel.serviceChannels': 'Service Channels',
'menu.channel.priceArea': 'Price Area',
@ -722,7 +724,18 @@ export default {
'order.priceCheck.table.otherFeesHint': 'Hover to see fee names',
'order.priceCheck.table.totalCost': 'Total Cost (CNY)',
'order.priceCheck.table.operation': 'Action',
'order.priceCheck.table.viewDetail': 'Details',
'order.priceCheck.table.orderNow': 'Place order now',
'order.priceCheck.table.empty': 'No quotes yet. Run a search first.',
'order.priceCheck.tag.priceOptimal': 'Best price',
'order.priceCheck.tag.timeOptimal': 'Fastest',
'order.priceCheck.customerGuide.drawerTitle': 'Customer guide',
'order.priceCheck.customerGuide.drawerTitleWithProduct': '{product} · Customer guide',
'order.priceCheck.customerGuide.empty': 'No customer guide configured for this product',
'order.priceCheck.customerGuide.loadFailed': 'Failed to load customer guide',
'order.priceCheck.customerGuide.missingProduct': 'Cannot identify product; customer guide unavailable',
'order.priceCheck.pagination.total': 'Total {count} records',
'order.priceCheck.pagination.prev': 'Prev',
@ -1261,6 +1274,16 @@ export default {
'system.menu.status.show': 'Show',
'system.menu.status.hide': 'Hide',
'system.menu.iconPicker.choose': 'Choose icon',
'system.menu.iconPicker.title': 'Choose menu icon',
'system.menu.iconPicker.placeholder': 'Enter or pick an icon, e.g. IconBook',
'system.menu.iconPicker.searchPlaceholder': 'Search icon name, e.g. Book, User, File',
'system.menu.iconPicker.current': 'Selected',
'system.menu.iconPicker.common': 'Common',
'system.menu.iconPicker.all': 'All icons',
'system.menu.iconPicker.searchResult': 'Search results',
'system.menu.iconPicker.empty': 'No matching icons',
// System - Role Management (Permission Assignment)
'system.role.permission.expandAll': 'Expand All',
'system.role.permission.collapseAll': 'Collapse All',
@ -2101,7 +2124,7 @@ export default {
'user.customer.fields.updateTime': 'Update Time',
'user.customer.fields.updateBy': 'Update By',
'user.customer.fields.salesPerson': 'Sales Person',
'user.customer.fields.customerService': 'Customer Service',
'user.customer.fields.customerService': 'Advisor',
'user.customer.fields.role': 'Role',
'user.customer.role.customer': 'Customer',
'user.customer.role.admin': 'Admin',
@ -2371,6 +2394,7 @@ export default {
'channel.salesProducts.tabs.config': 'Product config',
'channel.salesProducts.tabs.price': 'Product pricing',
'channel.salesProducts.tabs.rules': 'Shipping rules',
'channel.salesProducts.tabs.customerGuide': 'Customer guide',
'channel.salesProducts.tabs.records': 'Publish records',
'channel.salesProducts.actions.save': 'Save',
@ -2380,6 +2404,17 @@ export default {
'channel.salesProducts.actions.publish': 'Publish',
'channel.salesProducts.actions.rollback': 'Rollback to this version',
'channel.salesProducts.customerGuide.editing': 'Editing',
'channel.salesProducts.customerGuide.cancel': 'Cancel',
'channel.salesProducts.customerGuide.useTemplate': 'Use template',
'channel.salesProducts.customerGuide.clear': 'Clear',
'channel.salesProducts.customerGuide.source': 'Markdown source',
'channel.salesProducts.customerGuide.preview': 'Preview',
'channel.salesProducts.customerGuide.previewEmpty': 'Preview will appear here after you enter Markdown',
'channel.salesProducts.customerGuide.placeholder': 'Enter customer-facing notes (Markdown supported)',
'channel.salesProducts.customerGuide.empty': 'No customer guide yet. Click Edit or Use template',
'channel.salesProducts.customerGuide.selectProduct': 'Select a sales product on the left first',
'channel.salesProducts.price.effectiveVersion': 'Effective version: {version}',
'channel.salesProducts.rules.schemeTitle': 'Shipping scheme',

View File

@ -77,12 +77,14 @@ export default {
'navbar.docs': '文档中心',
'navbar.action.locale': '切换为中文',
'menu.order': '订单',
'menu.order.orderGuide': '新手指引',
'menu.order.priceCheck': '我要查价',
'menu.order.placeOrder': '我要下单',
'menu.order.templateMapping': '模板字段映射',
'menu.order.myOrders': '我的订单',
'menu.order.batchJobs': '批量任务',
'menu.channel': '渠道',
'menu.channel.channelGuide': '新手指引',
'menu.channel.salesProducts': '销售产品',
'menu.channel.serviceChannels': '服务渠道',
'menu.channel.priceArea': '价格分区',
@ -607,7 +609,18 @@ export default {
'order.priceCheck.table.otherFeesHint': '悬停查看各项费用名称',
'order.priceCheck.table.totalCost': '总费用(CNY)',
'order.priceCheck.table.operation': '操作',
'order.priceCheck.table.viewDetail': '详情',
'order.priceCheck.table.orderNow': '立刻下单',
'order.priceCheck.table.empty': '暂无报价,请先查询',
'order.priceCheck.tag.priceOptimal': '价格最优',
'order.priceCheck.tag.timeOptimal': '时效最优',
'order.priceCheck.customerGuide.drawerTitle': '对客说明',
'order.priceCheck.customerGuide.drawerTitleWithProduct': '{product} · 对客说明',
'order.priceCheck.customerGuide.empty': '该产品暂未配置对客说明',
'order.priceCheck.customerGuide.loadFailed': '加载对客说明失败',
'order.priceCheck.customerGuide.missingProduct': '无法识别产品,暂无法查看对客说明',
'order.priceCheck.pagination.total': '共 {count} 条记录',
'order.priceCheck.pagination.prev': '上一页',
@ -1146,6 +1159,16 @@ export default {
'system.menu.status.show': '显示',
'system.menu.status.hide': '隐藏',
'system.menu.iconPicker.choose': '选择图标',
'system.menu.iconPicker.title': '选择菜单图标',
'system.menu.iconPicker.placeholder': '请输入或选择图标,如 IconBook',
'system.menu.iconPicker.searchPlaceholder': '搜索图标名称,如 Book、User、File',
'system.menu.iconPicker.current': '当前选中',
'system.menu.iconPicker.common': '常用图标',
'system.menu.iconPicker.all': '全部图标',
'system.menu.iconPicker.searchResult': '搜索结果',
'system.menu.iconPicker.empty': '未找到匹配图标',
// 系统 - 角色管理(权限分配相关)
'system.role.permission.expandAll': '全部展开',
'system.role.permission.collapseAll': '全部收起',
@ -2119,7 +2142,7 @@ export default {
'user.customer.fields.updateTime': '更新时间',
'user.customer.fields.updateBy': '更新人',
'user.customer.fields.salesPerson': '所属销售',
'user.customer.fields.customerService': '所属客服',
'user.customer.fields.customerService': '所属顾问',
'user.customer.fields.role': '角色',
'user.customer.role.customer': '客户',
'user.customer.role.admin': '管理员',
@ -2391,6 +2414,7 @@ export default {
'channel.salesProducts.tabs.config': '产品配置',
'channel.salesProducts.tabs.price': '产品卖价',
'channel.salesProducts.tabs.rules': '排货规则',
'channel.salesProducts.tabs.customerGuide': '对客说明',
'channel.salesProducts.tabs.records': '发布记录',
'channel.salesProducts.actions.save': '保存',
@ -2400,6 +2424,17 @@ export default {
'channel.salesProducts.actions.publish': '发布',
'channel.salesProducts.actions.rollback': '回滚到此版本',
'channel.salesProducts.customerGuide.editing': '编辑中',
'channel.salesProducts.customerGuide.cancel': '取消',
'channel.salesProducts.customerGuide.useTemplate': '使用模板',
'channel.salesProducts.customerGuide.clear': '清空',
'channel.salesProducts.customerGuide.source': 'Markdown 源码',
'channel.salesProducts.customerGuide.preview': '预览',
'channel.salesProducts.customerGuide.previewEmpty': '输入 Markdown 后可在此预览',
'channel.salesProducts.customerGuide.placeholder': '请输入对客说明,支持 Markdown 语法',
'channel.salesProducts.customerGuide.empty': '暂无对客说明,可点击编辑或使用模板',
'channel.salesProducts.customerGuide.selectProduct': '请先在左侧选择销售产品',
'channel.salesProducts.price.effectiveVersion': '生效版本: {version}',
'channel.salesProducts.rules.schemeTitle': '排货方案',

View File

@ -12,6 +12,17 @@ const CHANNEL: AppRouteRecordRaw = {
order: 4,
},
children: [
{
path: 'guide',
name: 'ChannelGuide',
component: () => import('@/views/channel/channelGuide/index.vue'),
meta: {
locale: 'menu.channel.channelGuide',
requiresAuth: true,
order: 0,
icon: 'icon-book',
},
},
{
path: 'salesProducts',
name: 'SalesProducts',

View File

@ -12,6 +12,17 @@ const ORDER: AppRouteRecordRaw = {
order: 3,
},
children: [
{
path: 'guide',
name: 'OrderGuide',
component: () => import('@/views/order/orderGuide/index.vue'),
meta: {
locale: 'menu.order.orderGuide',
requiresAuth: true,
order: 0,
icon: 'icon-book',
},
},
{
path: 'priceCheck',
name: 'PriceCheck',

View File

@ -0,0 +1,12 @@
import * as Icons from '@arco-design/web-vue/es/icon'
/** Arco Design 图标组件名列表(Icon 前缀,按字母排序) */
export const ARCO_ICON_NAMES: string[] = Object.keys(Icons)
.filter((key) => key.startsWith('Icon') && key !== 'Icon')
.sort((a, b) => a.localeCompare(b))
export function filterArcoIconNames(keyword: string): string[] {
const q = keyword.trim().toLowerCase()
if (!q) return ARCO_ICON_NAMES
return ARCO_ICON_NAMES.filter((name) => name.toLowerCase().includes(q))
}

View File

@ -24,10 +24,12 @@ export const SUB_MENU_DEFAULT_ICONS: Record<string, string> = {
'menu.finance.rechargeRecord': 'IconList',
'menu.finance.accountFlow': 'IconSwap',
'menu.finance.receivablePayable': 'IconFile',
'menu.order.orderGuide': 'IconBook',
'menu.order.priceCheck': 'IconSearch',
'menu.order.placeOrder': 'IconPlus',
'menu.order.myOrders': 'IconOrderedList',
'menu.order.batchJobs': 'IconSchedule',
'menu.channel.channelGuide': 'IconBook',
'menu.channel.salesProducts': 'IconTag',
'menu.channel.serviceChannels': 'IconSend',
'menu.channel.priceArea': 'IconLocation',

View File

@ -14,6 +14,8 @@ const routeNameMap: Record<string, string> = {
'/order/templateMapping': 'OrderTemplateMapping',
'order_batch_jobs': 'OrderBatchJobs',
'/order/batchJobs': 'OrderBatchJobs',
'order_guide': 'OrderGuide',
'/order/guide': 'OrderGuide',
'order': 'Order',
'/order': 'Order',
@ -108,6 +110,8 @@ const routeNameMap: Record<string, string> = {
// 渠道相关
'channel': 'Channel',
'/channel': 'Channel',
'channel_guide': 'ChannelGuide',
'/channel/guide': 'ChannelGuide',
'channel_sales_products': 'SalesProducts',
'/channel/salesProducts': 'SalesProducts',
'channel_service_channels': 'ServiceChannels',

View File

@ -0,0 +1,173 @@
# 渠道中心新手指引
> 本文面向第一次接触 **渠道模块(OMS Channel)** 的运营、产品与开发同学,帮助你理解「主数据 → 服务渠道买价 → 销售产品卖价与排货 → 发布快照 → 订单计费」的完整配置链路。
---
## 1. 系统是什么?
**渠道模块**是 OMS 的 **物流产品与价格配置中心**,为订单模块提供:
- **地理分区**:价格分区、黑名单区、偏远分区;
- **计重规则**:体积重、计费重、重量进位;
- **服务渠道**:下游通道及 **买价(成本)**;
- **销售产品**:客户运输方式及 **卖价(应收)**、排货、对客说明;
- **发布机制**:版本化快照,供订单创建绑定。
API 路径前缀 `/channel/*`,Swagger 分组 **OMS-Channel**。
**心智模型:** 渠道决定订单 **卖多少、走哪条渠道、成本多少、能否发货、如何计重**。订单读 **发布快照**,而非实时改库后的配置。
---
## 2. 核心概念
| 概念 | 说明 |
|------|------|
| **KPI 分区** | `t_kpi_area` + `t_kpi_area_info`,按 AreaType 区分用途 |
| **价格分区** | AreaType=price,价格矩阵的「分区」因素 |
| **黑名单区** | AreaType=blacklist,命中则创建拒绝 |
| **偏远分区** | AreaType=remote,标记 order.IsRemote |
| **计重规则/进位** | 计费重计算与阶梯进位 |
| **服务渠道** | 下游通道 + 渠道买价 |
| **销售产品** | ProductCode = 下单运输方式编码 |
| **排货 Scheme** | costLowest / timeFastest / overallBest |
| **发布快照** | `t_sales_product_publish_record.Content` |
邮编匹配:**左开右闭** `(left, right]`。
---
## 3. 推荐配置顺序
```
主数据
① 价格分区 ② 黑名单 ③ 偏远 ④ 计重规则 ⑤ 计重进位
服务渠道(成本)
⑥ 渠道组 + 渠道 ⑦ 买价矩阵 ⑧ 发布渠道价格
销售产品(卖价)
⑨ 创建产品 ⑩ 产品配置(关联分区/计重)
⑪ 产品卖价 ⑫ 排货规则 ⑬ 对客说明
⑭ 发布 rules ⑮ 发布 price
```
排货需要渠道 **买价可算**,否则预报会「无可用渠道」。
---
## 4. 发布与回滚
### 两条独立发布流
| operatorType | 快照内容 | 订单字段 |
|--------------|----------|----------|
| `price` | 卖价 + 价格分区明细 | SalesProductPriceVersion |
| `rules` | 产品 + 排货 + 黑名单/偏远/计重 | SalesProductRulesVersion |
### 操作
1. 预览:`GET .../publish/preview`
2. 发布:`POST .../publish` → 版本号 `yyyyMMddHHmmss`,Status=生效
3. 回滚:`POST .../publish/rollback` → 还原配置,不新建发布记录
无发布记录时订单使用 **LIVE-{productId}** 读当前库配置。
---
## 5. 排货详解
**销售产品 → 排货规则 Tab:**
- **Scheme**:成本最低(默认)/ 时效最快 / 综合最优(权重评分)
- **服务商列表**:渠道、权重、指定客户(空=全部)
订单 `ConfirmServiceChannelAndPayableAsync`:过滤候选 → 试算应付 → 按 Scheme 选中 → 写 ChannelCode 与下游字段。
---
## 6. 价格矩阵结构
卖价与买价结构相同:
- **价格项**:费用项、单位、币种
- **计费因素**:货物类型、分区、重量、仓库、客户等
- **价格明细**:因素组合 + 公式 + 客户组价格
---
## 7. 各菜单说明
| 菜单 | 路径 | 功能 |
|------|------|------|
| 新手指引 | `/channel/guide` | 本文档 |
| 销售产品 | `/channel/salesProducts` | 配置/卖价/排货/发布/对客说明 |
| 服务渠道 | `/channel/serviceChannels` | 渠道与买价 |
| 价格分区 | `/channel/priceArea` | 价格用地理分区 |
| 黑名单区 | `/channel/blacklist` | 禁运区域 |
| 偏远分区 | `/channel/remoteZones` | 偏远标记 |
| 计重规则 | `/channel/weightRules` | 计费重逻辑 |
| 计重进位 | `/channel/weightRounding` | 重量进位 |
| 自动触发 | `/channel/autoTrigger` | 订单事件触发 |
| 问题模板 | `/channel/issueTemplate` | 异常话术 |
| 标签设计 | `/channel/labelDesign` | 面单模板 |
### 销售产品 Tab
| Tab | 内容 |
|-----|------|
| 产品配置 | 基础信息、关联分区与计重 |
| 产品卖价 | 卖价矩阵、价格发布 |
| 排货规则 | Scheme、服务商、发布 |
| 发布记录 | 历史版本、回滚 |
| 对客说明 | Markdown,查价页可查看 |
---
## 8. 与订单模块衔接
```
渠道配置+发布 → 订单 CreateParcelCoreAsync
├─ 读 rules/price 快照
├─ 黑名单/偏远/计重
├─ 分区匹配 + 应收
└─ 排货 + 应付
```
| 订单报错 | 排查 |
|----------|------|
| 产品未启用 | 销售产品状态 |
| 价格分区未匹配 | 分区明细、邮编范围 |
| 无可用渠道 | 排货列表、渠道买价、客户白名单 |
| 黑名单命中 | 黑名单明细 + 产品关联 |
---
## 9. 主要 API
```
/channel/sales-product/*
/channel/service-channel/*
/channel/kpi-area/*
/channel/weight-rule/*
/channel/weight-rounding/*
```
---
## 10. 关键数据表
`t_kpi_area*`、`t_weight_rule*`、`t_weight_rounding*`、`t_service_channel*`、`t_sales_product*`、`t_sales_product_publish_record`
---
## 11. 后端代码索引
| 主题 | 文件 |
|------|------|
| 销售产品 | `SalesProductService.cs` |
| 服务渠道 | `ServiceChannelService.cs` |
| KPI 分区 | `KpiAreaService.cs` |
| 排货 | `AllocationResolver.cs` |
| 订单消费 | `OrderService.ParcelBilling.cs` |

View File

@ -0,0 +1,47 @@
<script lang="ts" setup>
import MarkdownViewer from '@/components/MarkdownViewer/index.vue';
import guideContent from './guide.md?raw';
defineOptions({
name: 'ChannelGuide',
});
</script>
<template>
<div class="channel-guide-page">
<div class="channel-guide-card">
<div class="guide-pane">
<MarkdownViewer :content="guideContent" />
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.channel-guide-page {
height: 100%;
width: 100%;
padding: 16px;
background: #f0f3f4;
overflow: hidden;
box-sizing: border-box;
}
.channel-guide-card {
height: 100%;
background: #fff;
border-radius: 8px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.guide-pane {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 24px 24px;
box-sizing: border-box;
}
</style>

View File

@ -0,0 +1,50 @@
# 对客说明
> 面向客户的运输产品说明,可按产品单独维护。支持 Markdown 语法编辑。
---
## 产品类型
以仓到门为主,接受自寄送仓及平台头程运力揽收。
## 运输时效
预计运输时效为11-15个工作日,(从出库开始计算至目的国派送完成)。不同国家时效不同,请以查询物流方案页展示结果为准。
> 特殊情况除外(包括但不限于:疫情、不可抗力、海关查验、政策调整以及节假日等)。
## 运输国家
美国
## 品类限制
化妆品走货属性:
(1)只接受化妆品类液体、粉末和膏状产品及绘画颜料、染料粉、口腔清洁剂、墨水等;所有含酒精的都不收,液体不超过500ml;
- a.化妆品类液体:甲油胶、精油、爽肤水、乳液、面膜、眼膜、眼线液、妆前精华液、卸妆水(含酒精不收)等;
- b.化妆品类膏体:口红、唇膏、眉笔、眼线笔、洗面奶、防晒霜(乳液可走,喷雾状不收)、睫毛膏、面霜、眼霜、遮瑕膏、芦荟胶、BB霜、粉底液、妆前乳、洗发水、沐浴露、身体乳、护发素、发膜、唇彩、唇釉等;
- c.化妆品类粉末:眉粉、眼影、散粉、粉饼、高光粉、腮红等
(2)内置电池:带电产品,纽扣电池类型,一个包裹控制10粒以内,产品配套电池5号或者7号两节;ps:颈挂式充电耳机等电子内产品带有电池的;
- a.外置电池:每个包裹不得超过2块配套的电池;2000毫安以内;ps:遥控车,早教机等;
- b.弱磁产品:USB接口,插孔式耳机,小家电等电子内产品不带电池的;
侵权仿牌产品均不可发货
化妆品禁寄物品:
1. 危险品不承运:包括易燃易爆物品、有毒物品、有污染性物品、放射性物品、不明气体等(如卸甲油);
2. 动植物类、液状、粉状、物体类不承运:包括鲜活生物,香烟或其包装、药物药品、食品等;
3. 仿牌、黄色及盗版类以及所有法律法规禁止或限制携带或运输的物品不承运;
4. 货物内外包装和货物上有航空违禁品标识的不承运;
5. U盘、光盘、移动硬盘等带有移动存储功能的产品均不做承运
6. 枪形、武器形物品,刀具(包含非管制型如剪刀、厨房刀)不承运
7. 无人机及配件禁止出口无法承运
## 贸易条款
DDP

View File

@ -14,6 +14,7 @@
publishSalesProduct,
rollbackSalesProductPublish,
saveSalesProductAllocation,
saveSalesProductCustomerGuide,
updateSalesProduct,
updateSalesProductPrice,
} from '@/api/channel/salesProduct';
@ -39,6 +40,8 @@
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
import Sortable from 'sortablejs';
import MarkdownDocPanel from '@/components/MarkdownDocPanel/index.vue';
import customerGuideTemplate from './customerGuideTemplate.md?raw';
defineOptions({
name: 'SalesProducts',
@ -91,6 +94,7 @@
{ key: 'price', labelKey: 'channel.salesProducts.tabs.price' },
{ key: 'rules', labelKey: 'channel.salesProducts.tabs.rules' },
{ key: 'records', labelKey: 'channel.salesProducts.tabs.records' },
{ key: 'customerGuide', labelKey: 'channel.salesProducts.tabs.customerGuide' },
]);
// 表单数据
@ -110,6 +114,10 @@
preStorageScript: '',
});
const customerGuideMarkdown = ref('');
const customerGuideSaving = ref(false);
const customerGuidePanelRef = ref<InstanceType<typeof MarkdownDocPanel> | null>(null);
// 分组选项
const groupOptions = ref([
{ label: '专线', value: '专线' },
@ -903,6 +911,7 @@
productDescription: p.productDescription ?? p.ProductDescription ?? '',
preStorageScript: p.preStorageScript ?? p.PreStorageScript ?? '',
});
customerGuideMarkdown.value = p.customerGuideMarkdown ?? p.CustomerGuideMarkdown ?? '';
await loadSalesProductRelatedByTab(product.id, activeTab.value);
} catch (e: any) {
console.error(e);
@ -987,6 +996,28 @@
}
};
async function handleSaveCustomerGuide(content: string) {
const pid = selectedProduct.value;
if (pid == null) {
Message.warning('请先选择产品');
return;
}
customerGuideSaving.value = true;
try {
await saveSalesProductCustomerGuide({
salesProductId: pid,
customerGuideMarkdown: content,
});
customerGuideMarkdown.value = content;
customerGuidePanelRef.value?.stopEditing?.();
Message.success('对客说明已保存');
} catch (e: any) {
Message.error(e?.message || '保存对客说明失败');
} finally {
customerGuideSaving.value = false;
}
}
// 状态开关滑动切换:记录触摸起点
const statusTouchStartX = ref(0)
const STATUS_SWIPE_THRESHOLD = 40
@ -2788,6 +2819,20 @@
</div>
</div>
<!-- 对客说明 tab -->
<div v-if="activeTab === 'customerGuide'" class="customer-guide-section">
<MarkdownDocPanel
v-if="selectedProduct != null"
ref="customerGuidePanelRef"
:content="customerGuideMarkdown"
:saving="customerGuideSaving"
:template-content="customerGuideTemplate"
:empty-hint="t('channel.salesProducts.customerGuide.empty')"
@save="handleSaveCustomerGuide"
/>
<a-empty v-else :description="t('channel.salesProducts.customerGuide.selectProduct')" />
</div>
<!-- 发布记录tab内容 -->
<div v-if="activeTab === 'records'" class="publish-records-section">
<div class="publish-records-container">
@ -4044,6 +4089,14 @@
overflow: hidden;
}
.customer-guide-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.price-header {
display: flex;
justify-content: space-between;

View File

@ -24,7 +24,6 @@
<div v-if="shellReady" ref="rightPaneRef" class="login-pane login-pane--right">
<div ref="loginFormRef" :class="['login-form', { 'login-form--ready': shellReady }]">
<div class="login-form-toolbar">
<ContactService v-if="isCustomerLogin" variant="login" />
<LoginLangToggle />
<a-tooltip :content="isLightMode ? t('login.theme.toNight') : t('login.theme.toDay')">
<a-button
@ -73,7 +72,6 @@ import WechatLogin from './components/wechat-login.vue';
import LoginCharacters from './components/LoginCharacters.vue';
import LoginHero from './components/LoginHero.vue';
import LoginLangToggle from './components/LoginLangToggle.vue';
import ContactService from '@/components/ContactService/index.vue';
import {
LOGIN_CHARACTERS_KEY,
type LoginCharactersApi,

View File

@ -0,0 +1,561 @@
# 订单中心新手指引
> 本文面向第一次接触 **订单模块(OMS Order)** 的运营、客服、产品与开发同学。
> **第三章** 以 `POST /order/parcel/create` 为核心,用流程图拆解 `CreateParcelCoreAsync` 的**业务校验、分区运算、计重算价、排货选渠**全链路。
---
## 1. 系统是什么?
**订单模块**负责:查价试算、小包预报(创建订单)、订单全生命周期、Excel 导入、批量异步任务。
后端入口:`OrderService`(`Oms.Application.Services.Orders`),Swagger 分组 **OMS-Order**。
**心智模型:** 创建订单 = **小包预报流水线** — 绑快照 → 校验地址 → 计重 → 匹配分区 → 算应收 → 排货算应付 → 状态 `predeclared`。
---
## 2. 核心概念速查
| 概念 | 说明 |
|------|------|
| **ProductCode** | 客户选的运输方式,对应销售产品 |
| **ChannelCode** | 服务渠道,**系统排货自动写入**,客户不手选 |
| **Rules 快照** | 计重/黑名单/偏远/排货/providers,版本 `SalesProductRulesVersion` |
| **Price 快照** | 卖价矩阵 + 价格分区明细,版本 `SalesProductPriceVersion` |
| **zoneKeys** | 价格分区匹配后生成的「分区键」列表,用于价格矩阵行匹配 |
| **forecast 重量** | 预报阶段计重行,应收费用用它 |
| **downstream 重量** | 下游计重行,**排货应付费用用它**(可与 forecast 相同) |
| **ChargingStage** | `forecast` / `inbound` / `outbound`,费用按阶段区分 |
### 订单状态(创建相关)
| 状态 | 含义 |
|------|------|
| `draft` | 草稿;预报计费失败时保留,并写 `PredeclareFailReason` |
| `predeclared` | **创建成功**后的默认状态 |
---
## 3. 创建订单接口全链路(重点)
### 3.1 API 与代码入口
| 项目 | 值 |
|------|-----|
| **HTTP** | `POST /order/parcel/create` |
| **方法** | `OrderService.CreateParcelAsync` → `CreateParcelCoreAsync` |
| **文件** | `OrderService.ParcelBilling.cs` |
| **算价引擎** | `ParcelBillingCalculator.cs` |
| **排货选渠** | `AllocationResolver.cs` |
以下入口最终都调用同一核心方法:
| 入口 | API / 动作 |
|------|------------|
| 页面下单 | `POST /order/parcel/create` |
| JSON 批量 | `POST /order/parcel/batch-create`(≤2000) |
| Excel 导入 | `POST /order/upload` → 行映射后调用 |
| MQ Worker | `parcel-create` / `import-row` |
---
### 3.2 总览流程图
```mermaid
flowchart TD
A[POST /order/parcel/create] --> B{RequestId 已存在?}
B -->|是| Z1[返回已有订单 BuildParcelCreateOutput]
B -->|否| C[NormalizeParcelCreateInput<br/>清空 TrackNo/ServiceNo]
C --> D[ResolveEffectiveCustomer 解析客户]
D --> E[ValidateParcelCreateInputAsync<br/>业务校验]
E -->|失败| X1[抛 BusinessException<br/>不落库]
E --> F[加载 SalesProduct 须启用]
F -->|失败| X1
F --> G[ResolveSnapshotBindingAsync<br/>绑 rules+price 快照]
G -->|失败| X1
G --> H[解析目的国 Country]
H --> I{IsBlacklistHitAsync?}
I -->|命中| X1
I --> J[生成 ServiceNo NE*]
J --> K[Insert t_order 草稿]
K --> L[SaveParcelChildren<br/>收发件/申报/4行重量]
L --> M{预报计费 try}
M --> N[偏远判定 IsRemote]
N --> O[ApplyChargeableWeightAsync 计重]
O --> P[ResolvePriceZoneKeysAsync 分区]
P --> Q[UpsertReceivableFeesAsync 应收]
Q --> R[ConfirmServiceChannelAndPayableAsync<br/>排货+应付]
R --> S[OrderStatus=predeclared]
S --> T[轨迹 Upsert + 自动触发]
T --> Z2[返回 OrderParcelCreateOutput]
M -->|异常| U[MarkParcelDraftFailedAsync<br/>draft + PredeclareFailReason]
U --> X2[抛 BusinessException]
```
> **关键分界:** 黑名单校验在 **Insert 之前**,失败完全不落库;计重/分区/算价/排货在 **Insert 之后** 的 `try` 块内,失败会保留 `draft` 订单并记录失败原因。
---
### 3.3 阶段一:入参规范化与业务校验(落库前)
#### 3.3.0 客户身份解析 `ResolveEffectiveCustomerAsync`
在规范化与校验之前,系统会先解析「这笔单属于哪个客户」:
```mermaid
flowchart TD
C0[ResolveEffectiveCustomerAsync] --> C1{当前登录身份}
C1 -->|客户账号| C2[忽略入参 CustomerId<br/>强制使用会话客户]
C1 -->|管理员/运营| C3{入参 CustomerId > 0?}
C3 -->|否| CE1[客户ID不能为空]
C3 -->|是| C4[按 CustomerId 查库]
C4 --> C5{客户存在且 Status=Normal?}
C5 -->|否| CE2[客户不存在或状态不可用]
C5 --> C6[写回 input.CustomerId]
```
> 客户门户下单时无需手填 `CustomerId`;后台代客下单必须显式传入有效客户 ID。
#### 3.3.1 规范化 `NormalizeParcelCreateInput`
| 字段 | 处理 |
|------|------|
| `TrackNo` | 强制置 `null`(不允许客户端传入) |
| `ServiceNo` | 强制置 `null`(系统生成) |
| `ClientNo` | Trim;空则 `null` |
| `RequestId` | Trim;用于幂等 |
#### 3.3.2 业务校验 `ValidateParcelCreateInputAsync`
```mermaid
flowchart TD
V0[开始校验] --> V1{ClientNo 非空?}
V1 -->|否| E1[客户单号不能为空]
V1 --> V2{CustomerId > 0?}
V2 -->|否| E2[客户ID不能为空]
V2 --> V3{同客户 ClientNo 唯一?}
V3 -->|重复| E3[客户单号已存在]
V3 --> V4{ProductCode 非空?}
V4 -->|否| E4[产品代码不能为空]
V4 --> V5{WarehouseId > 0?}
V5 -->|否| E5[仓库ID不能为空]
V5 --> V6{长宽厚实重均 > 0?}
V6 -->|否| E6[件重尺必须大于0]
V6 --> V7{CargoAttribute 合法?}
V7 -->|否| E7[货物属性不合法<br/>general/electronics/sensitive]
V7 --> V8{ExportCustomsMode 合法或空?}
V8 -->|否| E8[出口报关方式不合法]
V8 --> V9{DeclaredCurrency 非空?}
V9 -->|否| E9[申报币种不能为空]
V9 --> V10{Quantity > 0?}
V10 -->|否| E10[包裹数量不能为空]
V10 --> V11{收件人完整?}
V11 -->|否| E11[收件人信息不完整<br/>姓名/国家/邮编/地址1]
V11 --> V12{发件人姓名非空?}
V12 -->|否| E12[发件人姓名不能为空]
V12 --> V13{申报明细 Items 非空?}
V13 -->|否| E13[申报明细不能为空]
V13 --> V14[逐行校验申报品<br/>EnName/UnitPrice/UnitWeight/Quantity]
V14 --> V15{仓库存在?}
V15 -->|否| E15[仓库不存在]
V15 --> V16{客户存在且状态正常?}
V16 -->|否| E16[客户不存在或状态不可用]
V16 --> OK[校验通过]
```
| 校验项 | 规则 | 失败文案 |
|--------|------|----------|
| 幂等 | `RequestId` 已有订单则直接返回 | — |
| 客户单号 | 非空;同 `CustomerId` 下唯一 | 客户单号不能为空 / 已存在 |
| 产品 | `ProductCode` 非空 | 产品代码不能为空 |
| 仓库 | `WarehouseId > 0` 且记录存在 | 仓库ID不能为空 / 仓库不存在 |
| 件重尺 | 长/宽/高/实重均 > 0 | 件重尺必须大于0 |
| 货物属性 | `general` / `electronics` / `sensitive` | 货物属性不合法 |
| 申报 | 币种、数量、明细行必填完整 | 见上 |
| 收件人 | 姓名、国家、邮编、地址1 | 收件人信息不完整 |
| 客户 | 存在且 `Status=Normal` | 客户不存在或状态不可用 |
#### 3.3.3 产品与快照(仍在落库前)
```mermaid
flowchart TD
P1[GetByProductCode] --> P2{产品存在且 IsActive?}
P2 -->|否| E[销售产品不存在或未启用]
P2 --> P3[ResolveSnapshotBindingAsync bindTime=Now]
P3 --> P4[取 rules 生效发布记录]
P3 --> P5[取 price 生效发布记录]
P4 -->|无| P4L[BuildLiveRulesSnapshot 内存拼装]
P4 -->|有| P4S[反序列化 rules Content]
P5 -->|无| P5L[BuildLivePriceSnapshot]
P5 -->|有| P5S[反序列化 price Content]
P5L --> P5C{Prices.Count>0?}
P5C -->|否| E2[未命中价格快照版本]
P4L --> P4R{WeightRuleId 有值?}
P4R -->|否| E3[未命中规则快照版本]
P3 --> P6[写入绑定版本号<br/>RulesVersion / PriceVersion]
P6 --> P7[解析目的国 CountryCode]
P7 --> P8{黑名单命中?}
P8 -->|是| E4[收件地址命中黑名单区域]
```
**快照选取规则:** `PublishTime <= bindTime` 且 `Status=生效` 的**最新**发布记录;无记录则 `LIVE-{productId}`,价格侧必须有启用价格项,规则侧 LIVE 时必须有 `WeightRuleId`。
---
### 3.4 阶段二:落库草稿
| 步骤 | 动作 | 写入表/字段 |
|------|------|-------------|
| 1 | `ResolveServiceNoAsync` | 运单号前缀 `NE` |
| 2 | `BuildDraftOrderEntity` | `t_order`:产品、国家、快照版本、初始 `draft`;`ChannelCode` 暂用 `ProductCode`,排货成功后覆盖 |
| 3 | `BuildInitialWeightRows` | `t_order_weight` 四行:`forecast` / `receive` / `ship` / `downstream` |
| 4 | `SaveParcelChildren` | `t_order_sender`、`t_order_recipient`、`t_order_declaration` |
**初始重量行:** 四行复制同一套长宽厚实重;`Cubic = L×W×H/1,000,000`;后续计重结果通过 `SyncWeightRows` 同步到全部四行。
---
### 3.5 阶段三:预报计费(try 块内)
#### 3.5.1 子流程总图
```mermaid
flowchart TD
T1[偏远 IsRemoteHitAsync] --> T2[ApplyChargeableWeightAsync<br/>仅算 forecast 行]
T2 --> T3[SyncWeightRows 同步四行重量]
T3 --> T4[ResolvePriceZoneKeysAsync]
T4 --> T5{zoneKeys 非空?}
T5 -->|否| E1[价格分区未匹配]
T5 --> T6[UpsertReceivableFeesAsync<br/>用 forecast.ChargeableWeight]
T6 --> T7[ConfirmServiceChannelAndPayableAsync<br/>用 downstream.ChargeableWeight]
T7 --> T8[status=predeclared]
```
---
#### 3.5.2 偏远判定 `IsRemoteHitAsync`
与黑名单逻辑对称,但**不阻断创建**,只写 `order.IsRemote`:
1. 解析有效 `RemoteAreaId`(产品 → 规则快照 → 快照内 Product);
2. 优先用快照内 `RemoteAreaInfos` 匹配;
3. 否则查库 `t_kpi_area_info`;
4. 匹配规则:国/省/市相等 + 邮编区间 **左开右闭** `(left, right]`。
---
#### 3.5.3 计重运算 `ApplyChargeableWeightAsync`
```mermaid
flowchart TD
W1[取 WeightRuleId] --> W2{有计重规则?}
W2 -->|否| E1[未配置计重规则]
W2 --> W3[加载规则明细 ruleInfos<br/>优先快照 WeightRuleInfos]
W3 --> W4[CalculateRawBillableWeight]
W4 --> W5[逐条规则按 Id 排序]
W5 --> W6[体积重 vw = L×W×H / 泡比因子]
W6 --> W7{Condition 命中?<br/>gw/vw 表达式}
W7 -->|否| W5
W7 -->|是| W8[raw = BillableWeight 为 gw 或 vw]
W8 --> W9[ApplyRounding 进位]
W9 --> W10[命中进位区间 Start~End]
W10 --> W11[ChargeableWeight = ceil raw/unit × unit]
W11 --> W12[写 forecast.VolumetricWeight / ChargeableWeight]
```
| 运算 | 公式/规则 |
|------|-----------|
| 体积 | `Cubic = L×W×H / 1,000,000`(保留 6 位) |
| 体积重 | `vw = L×W×H / VolumetricFactor`(保留 3 位) |
| 条件 | `Condition` 表达式,变量 `gw`/`vw`,如 `gw>=vw` |
| 原始计费重 | `BillableWeight=gw` 取实重,否则取体积重 |
| 进位 | 找 `StartValue ≤ raw ≤ EndValue` 的区间,`ceil(raw/Public)×Public` |
**失败文案:** 未配置计重规则 / 计重进位 / 计重规则条件均未命中 / 计重进位区间未匹配。
---
#### 3.5.4 价格分区匹配 `ResolvePriceZoneKeysAsync`
```mermaid
flowchart TD
Z1{price 快照含 PriceAreaInfos?}
Z1 -->|是| Z2[快照内逐条匹配国省市邮编]
Z1 -->|否| Z3[ResolveEffectivePriceAreaId]
Z3 --> Z4[查 t_kpi_area + info 明细]
Z2 --> Z5{命中一条?}
Z4 --> Z5
Z5 -->|否| Z6[返回 null → 创建失败]
Z5 --> Z7[BuildPriceZoneKeys]
Z7 --> Z8[生成 zoneKeys 列表<br/>分区名/Id/省/市等]
```
**地址匹配规则(黑名单/偏远/价格分区通用):**
| 维度 | 规则 |
|------|------|
| 国家 | 配置了则必须相等(忽略大小写) |
| 省/市 | 配置了则必须相等 |
| 邮编 | 配置了左右端点则必须落在区间内;数字按数值比,否则按字符串比 |
| 邮编区间 | **左开右闭** `(left, right]` |
**失败文案:** `价格分区未匹配(规则快照)`
---
#### 3.5.5 应收费用 `UpsertReceivableFeesAsync`
输入:`price 快照`、`forecast.ChargeableWeight`、`zoneKeys`、`CargoAttribute`、`CustomerGroup`、`chargingStage=forecast`。
```mermaid
flowchart TD
R1[遍历卖价快照中 Status=1 的价格项] --> R2[MatchPriceDetail 匹配明细行]
R2 --> R3{分区因素匹配 zoneKeys?}
R3 --> R4{货物属性匹配?}
R4 --> R5{仓库因素匹配?}
R5 --> R6{重量区间匹配?<br/>左开右闭}
R6 -->|命中| R7[CalculateDetailAmount]
R7 --> R8{公式类型}
R8 -->|total| R9[客户组价或 CalculatedValue]
R8 -->|unit| R10[单价 × 计费重]
R8 -->|continue| R11[首重价 + 续重 × 续单价]
R9 --> R12[写入 OrderFee type=receivable]
R10 --> R12
R11 --> R12
R2 -->|无匹配| R1
R12 --> R13{有费用行 或 无启用项?}
R13 -->|有启用项但零匹配| E[产品卖价快照未配置或未匹配到有效价格]
```
| 价格明细匹配 | 说明 |
|--------------|------|
| 分区因素 | `FactorValuesJson` 中「分区」须与 `zoneKeys` 任一相等 |
| 货物属性 | 因素含货物类型时须匹配 `general/electronics/sensitive` |
| 重量段 | `(start, end]`,多段命中取**区间最窄**的一条 |
| 客户组价 | `CustomerGroupValues` 按客户组取价,否则 `CalculatedValue` |
#### 3.5.5.1 价格明细匹配 `MatchPriceDetail`(应收/应付共用)
```mermaid
flowchart TD
M0[遍历价格项全部 Details] --> M1{分区因素与 zoneKeys 相交?}
M1 -->|否| M0
M1 --> M2{货物类型因素匹配?<br/>无因素则跳过}
M2 -->|否| M0
M2 --> M3{仓库因素匹配?<br/>创建订单时通常无仓库因素}
M3 -->|否| M0
M3 --> M4{明细含重量因素?}
M4 -->|否| M5[暂记为候选 bestMatch]
M4 -->|是| M6[解析重量区间 左开右闭]
M6 --> M7{chargeableWeight 在区间内?}
M7 -->|否| M0
M7 --> M8[区间宽度 = end - start]
M8 --> M9{比当前 best 更窄?}
M9 -->|是| M10[更新 bestMatch]
M9 -->|否| M0
M5 --> M0
M10 --> M0
M0 -->|遍历结束| M11[返回 bestMatch 或 null]
```
**重量区间解析规则:**
| 配置写法 | 解析结果 | 示例 |
|----------|----------|------|
| 单值 `0.5` | `(0, 0.5]` | 0.5kg 以内 |
| 区间 `0,0.5` 或 `0-0.5` | `(0, 0.5]` | 同上 |
| 区间 `0.5,1` | `(0.5, 1]` | 0.5kg 以上至 1kg |
#### 3.5.5.2 金额计算公式 `CalculateDetailAmount`
匹配到明细行后,按 `CalculationFormula` 计算金额:
```mermaid
flowchart TD
F0[命中明细行] --> F1{CalculationFormula}
F1 -->|total| F2[客户组价 ?? CalculatedValue]
F1 -->|unit| F3[单价 × 计费重<br/>round 4 位小数]
F1 -->|continue| F4{能关联上一档 total 价?}
F4 -->|是| F5[basePrice + 计费重-区间起点 × 续单价]
F4 -->|否| F6[首重价 + ceil溢出/续重单位 × 续单价]
F1 -->|其他| F7[CalculatedValue 或 0]
```
| 公式类型 | 计算逻辑 | 展示公式示例 |
|----------|----------|--------------|
| `total` | 固定总价;优先读 `CustomerGroupValues[客户组]` | `12.5` |
| `unit` | `单价 × ChargeableWeight` | `3.2*1.5` |
| `continue`(关联上档) | 找同分区上一重量档的 `total` 价为 base,超出部分 × 续单价 | `10+(1.5-0.5)*8` |
| `continue`(单行首续重) | `首重价 + ceil((计费重-首重)/续重单位) × 续单价` | `10+2*5` |
> **应收**遍历卖价快照全部 `Status=1` 价格项,每项独立匹配明细;**应付**对渠道买价快照做同样逻辑。若存在启用项但全部匹配失败且金额为 0,则抛错。
---
#### 3.5.6 排货与应付 `ConfirmServiceChannelAndPayableAsync`
```mermaid
flowchart TD
A1[ResolveAllocationProviders<br/>规则快照 providers] --> A2[FilterProviders<br/>须 ServiceChannelId>0]
A2 --> A3[客户白名单过滤<br/>CustomerIds 空=全部]
A3 --> A4{候选数>0?}
A4 -->|否| E1[排货规则未匹配到可用服务渠道]
A4 --> A5[遍历每个候选渠道]
A5 --> A6{渠道启用?}
A6 --> A7[ResolveChannelPriceBinding<br/>渠道买价快照]
A7 --> A8[CalculatePayableFees 预览总成本]
A8 --> A9{应付>0?}
A9 -->|是| A10[加入 candidates]
A9 -->|否| A5
A10 --> A11{candidates>0?}
A11 -->|否| E2[排货规则未匹配到有效渠道买价]
A11 --> A12[AllocationResolver.SelectChannel]
A12 --> A13{Scheme}
A13 -->|costLowest| A14[应付总额最小]
A13 -->|timeFastest| A15[MinTime 最小]
A13 -->|overallBest| A16[Provider.Weight 最大]
A14 --> A17[写 ChannelCode + 下游字段]
A15 --> A17
A16 --> A17
A17 --> A18[重算应付并 UpsertFees payable]
```
| Scheme | 选渠规则 |
|--------|----------|
| `costLowest`(默认) | `TotalPayableCost` 最小,同价按序号 |
| `timeFastest` | 渠道 `MinTime` 最小 |
| `overallBest` | 服务商 `Weight`(评分)最大 |
**注意:** 排货应付使用 **`downstream` 行的 ChargeableWeight**(若为空则回退 forecast),与应收使用的 forecast 行可能相同(创建时四行已同步)。
**写入订单字段:** `ChannelCode`、`DownstreamSupplierCode`、`DownstreamAppKey`、`ChannelForecastCode` 等(`AllocationProviderSnapshotEnricher`)。
---
### 3.6 阶段四:成功收尾与失败处理
#### 成功
| 步骤 | 说明 |
|------|------|
| 状态 | `OrderStatus = predeclared`,清空 `PredeclareFailReason` |
| 轨迹 | `EnqueueSmallBagTrackUpsertAsync` 异步小包轨迹 |
| 触发 | `AutoTriggerRuntimeService.FireAsync(OrderCreate)`,失败**不影响**主流程 |
| 返回 | `OrderParcelCreateOutput`(运单号、费用摘要等) |
#### 失败 `MarkParcelDraftFailedAsync`
| 场景 | 订单状态 | 字段 |
|------|----------|------|
| 计重/分区/应收/排货任一抛错 | 保持 `draft` | `PredeclareFailReason = 异常信息` |
> 落库前校验失败(黑名单、快照缺失等):**不 Insert**,直接 `BusinessException`。
> 落库后计费失败:订单已存在,状态 `draft`,便于排查与重试。
---
### 3.7 创建过程数据流一览
```mermaid
flowchart LR
subgraph Input
I1[OrderParcelCreateInput]
end
subgraph Snapshot
S1[Rules Snapshot]
S2[Price Snapshot]
S3[Channel Price Snapshot]
end
subgraph Compute
C1[ChargeableWeight]
C2[zoneKeys]
C3[Receivable Fees]
C4[Payable Fees]
end
subgraph DB
D1[t_order]
D2[t_order_weight x4]
D3[t_order_fee]
end
I1 --> S1
I1 --> S2
S1 --> C1
C1 --> C2
S2 --> C2
C2 --> C3
S1 --> C4
S3 --> C4
C3 --> D3
C4 --> D3
I1 --> D1
I1 --> D2
C1 --> D2
```
---
### 3.8 常见失败原因速查(创建/预报)
| 报错关键词 | 排查方向 |
|------------|----------|
| 销售产品不存在或未启用 | 渠道 → 销售产品状态 |
| 未命中价格/规则快照 | 是否发布;LIVE 时是否有价格项/计重规则 |
| 收件地址命中黑名单 | 黑名单分区明细 + 产品关联 |
| 价格分区未匹配 | 价格分区是否覆盖目的邮编/省市 |
| 未配置计重规则/进位 | 产品配置 + 规则发布快照 |
| 计重规则条件均未命中 | 件重尺与规则 Condition |
| 卖价未匹配到有效价格 | 卖价矩阵分区/重量段/货物属性 |
| 排货无可用渠道 | 排货列表、客户白名单、渠道启用 |
| 排货无有效渠道买价 | 渠道买价发布、分区/重量段匹配 |
| 客户单号已存在 | 同客户重复下单 |
---
## 4. 查价 vs 创建
| 对比 | `POST /order/price-check` | `POST /order/parcel/create` |
|------|---------------------------|------------------------------|
| 落库 | 否 | 是 |
| 校验 | 目的国/重量/尺寸 | 完整收发件/申报/客户单号唯一等 |
| 黑名单 | 试算路径可失败记录 | **落库前硬拦截** |
| 排货 | 不绑 ChannelCode | 自动排货写库 |
| 算价引擎 | 同一 `ParcelBillingCalculator` | 同一套 |
---
## 5. 其他菜单(简表)
| 菜单 | 路径 | 功能 |
|------|------|------|
| 新手指引 | `/order/guide` | 本文档 |
| 我要查价 | `/order/priceCheck` | 运费试算 |
| 我要下单 | `/order/placeOrder` | 单笔/Excel 导入 |
| 我的订单 | `/order/myOrders` | 列表/付款/拦截/费用 |
| 模板字段映射 | `/order/templateMapping` | 自定义 Excel 列映射 |
| 批量任务 | `/order/batchJobs` | MQ 批处理监控 |
---
## 6. 后端代码索引
| 主题 | 文件 / 方法 |
|------|-------------|
| 创建主流程 | `OrderService.ParcelBilling.cs` → `CreateParcelCoreAsync` |
| 入参校验 | `ValidateParcelCreateInputAsync` |
| 快照绑定 | `ResolveSnapshotBindingAsync` |
| 计重 | `ApplyChargeableWeightAsync` + `ParcelBillingCalculator.CalculateRawBillableWeight` |
| 分区 | `ResolvePriceZoneKeysAsync` + `IsAreaMatched` |
| 应收 | `UpsertReceivableFeesAsync` + `CalculateReceivableFees` |
| 排货应付 | `ConfirmServiceChannelAndPayableAsync` + `AllocationResolver` |
| 查价 | `OrderService.ParcelQuote.cs` |
| API 路由 | `OrderService.cs` → `CreateParcelAsync` |
---
## 7. 推荐阅读顺序
1. 本文 **§3 流程图**(总览 → 校验 → 计重 → 分区 → 应收 → 排货);
2. **渠道新手指引**(快照发布、排货配置);
3. 用查价验证报价,再下一笔测试单,在「我的订单」看 `t_order_fee` 与 `ChannelCode`;
4. 预报失败时对照 **§3.8** 与订单 `PredeclareFailReason`。

View File

@ -0,0 +1,47 @@
<script lang="ts" setup>
import MarkdownViewer from '@/components/MarkdownViewer/index.vue';
import guideContent from './guide.md?raw';
defineOptions({
name: 'OrderGuide',
});
</script>
<template>
<div class="order-guide-page">
<div class="order-guide-card">
<div class="guide-pane">
<MarkdownViewer :content="guideContent" />
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.order-guide-page {
height: 100%;
width: 100%;
padding: 16px;
background: #f0f3f4;
overflow: hidden;
box-sizing: border-box;
}
.order-guide-card {
height: 100%;
background: #fff;
border-radius: 8px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.guide-pane {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 24px 24px;
box-sizing: border-box;
}
</style>

View File

@ -0,0 +1,86 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import DrawerPro from '@/components/DrawerPro/index.vue';
import useDrawer from '@/components/DrawerPro/useDrawer';
import MarkdownViewer from '@/components/MarkdownViewer/index.vue';
import { getSalesProductDetail } from '@/api/channel/salesProduct';
import { Message } from '@arco-design/web-vue';
defineOptions({ name: 'PriceCheckCustomerGuideDrawer' });
const { t } = useI18n();
const [register, { openDrawer, setDrawerProps, closeDrawer }] = useDrawer();
const loading = ref(false);
const guideContent = ref('');
const productTitle = ref('');
async function open(record: Record<string, any>) {
const productId = record?.productId ?? record?.id ?? record?.Id;
const name = String(record?.shippingMethod ?? record?.ShippingMethod ?? '').trim();
productTitle.value = name;
guideContent.value = '';
setDrawerProps({
title: name
? t('order.priceCheck.customerGuide.drawerTitleWithProduct', { product: name })
: t('order.priceCheck.customerGuide.drawerTitle'),
width: 960,
footer: false,
});
openDrawer(true);
if (productId == null || productId === '') {
Message.warning(t('order.priceCheck.customerGuide.missingProduct'));
return;
}
loading.value = true;
try {
const detail = await getSalesProductDetail(productId);
guideContent.value = String(
detail?.customerGuideMarkdown ?? detail?.CustomerGuideMarkdown ?? '',
).trim();
} catch (e: any) {
Message.error(e?.message || t('order.priceCheck.customerGuide.loadFailed'));
} finally {
loading.value = false;
}
}
defineExpose({ open, close: closeDrawer });
</script>
<template>
<DrawerPro :footer="false" padding-bottom="16px" @register="register" @cancel="closeDrawer">
<a-spin :loading="loading" class="customer-guide-drawer-spin">
<div class="customer-guide-drawer-body">
<div v-if="productTitle" class="drawer-product-name">{{ productTitle }}</div>
<MarkdownViewer v-if="guideContent" :content="guideContent" />
<a-empty
v-else-if="!loading"
:description="t('order.priceCheck.customerGuide.empty')"
/>
</div>
</a-spin>
</DrawerPro>
</template>
<style lang="less" scoped>
.customer-guide-drawer-spin {
width: 100%;
min-height: 200px;
}
.customer-guide-drawer-body {
padding: 0 4px 16px;
}
.drawer-product-name {
margin-bottom: 12px;
font-size: 16px;
font-weight: 600;
color: #1d2129;
}
</style>

View File

@ -4,7 +4,6 @@
import { Message } from '@arco-design/web-vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import HandsontablePro from '@/components/HandsontablePro/index.vue';
import PaginationPro from '@/components/PaginationPro/index.vue';
import { useTableContainerHeight } from '@/composables/useTableContainerHeight';
import { postOrderPriceCheck } from '@/api/order';
@ -12,6 +11,7 @@
import { getDictListAll } from '@/api/system/dict';
import { getSalesProductList } from '@/api/channel/salesProduct';
import { getWarehouseList } from '@/api/system/warehouse';
import CustomerGuideDrawer from './components/CustomerGuideDrawer.vue';
defineOptions({
name: 'PriceCheck',
@ -91,9 +91,12 @@
const otherFeeList = normalizeOtherFeeList(raw);
return {
...raw,
productId: raw.id ?? raw.Id ?? raw.productId ?? raw.ProductId,
productCode: raw.productCode ?? raw.ProductCode ?? '',
cargoType,
cargoTypeName: resolveCargoTypeLabel(String(cargoType), String(cargoTypeNameRaw)),
shippingMethod: raw.shippingMethod ?? raw.ShippingMethod ?? '',
warehouseId: raw.warehouseId ?? raw.WarehouseId,
warehouse: raw.warehouse ?? raw.Warehouse ?? '',
deliveryTime: raw.deliveryTime ?? raw.DeliveryTime ?? '',
volumetricWeight: raw.volumetricWeight ?? raw.VolumetricWeight ?? '',
@ -129,9 +132,15 @@
});
const minCost = Math.min(...processed.map((item) => item.totalCostNum));
const minDays = Math.min(...processed.map((item) => item.maxDeliveryDays));
processed.forEach((item) => {
processed.forEach((item, index) => {
item.isPriceOptimal = item.totalCostNum === minCost;
item.isTimeOptimal = item.maxDeliveryDays === minDays;
item.rowKey = [
item.productId ?? item.id ?? 'p',
item.cargoType ?? 'c',
item.warehouseId ?? item.warehouse ?? 'w',
index,
].join('-');
});
return processed.sort((a, b) => {
if (a.isPriceOptimal && !b.isPriceOptimal) return -1;
@ -183,75 +192,69 @@
return queryResults.value.slice(start, start + pageSize.value);
});
const hotColumns = computed(() => [
{ key: 'shippingMethod', label: t('order.priceCheck.table.shippingMethod'), width: 200 },
{ key: 'cargoTypeName', label: t('order.priceCheck.table.cargoType'), width: 100 },
{ key: 'warehouse', label: t('order.priceCheck.table.warehouse'), width: 120 },
{ key: 'deliveryTime', label: t('order.priceCheck.table.deliveryTime'), width: 100 },
{ key: 'volumetricWeight', label: t('order.priceCheck.table.volumetricWeight'), width: 100 },
{ key: 'chargeableWeight', label: t('order.priceCheck.table.chargeableWeight'), width: 100 },
{ key: 'costFormula', label: t('order.priceCheck.table.costFormula'), width: 180 },
{ key: 'freight', label: t('order.priceCheck.table.freight'), width: 100 },
{ key: 'otherFees', label: `${t('order.priceCheck.table.otherFees')} (${t('order.priceCheck.table.otherFeesHint')})`, width: 180 },
{ key: 'totalCost', label: t('order.priceCheck.table.totalCost'), width: 120 },
const customerGuideDrawerRef = ref<InstanceType<typeof CustomerGuideDrawer> | null>(null);
const priceTableColumns = computed(() => [
{
title: t('order.priceCheck.table.shippingMethod'),
dataIndex: 'shippingMethod',
slotName: 'shippingMethod',
width: 220,
fixed: 'left' as const,
},
{
title: t('order.priceCheck.table.cargoType'),
dataIndex: 'cargoTypeName',
width: 100,
},
{
title: t('order.priceCheck.table.warehouse'),
dataIndex: 'warehouse',
width: 120,
},
{
title: t('order.priceCheck.table.deliveryTime'),
dataIndex: 'deliveryTime',
slotName: 'deliveryTime',
width: 110,
},
{
title: t('order.priceCheck.table.chargeableWeight'),
dataIndex: 'chargeableWeight',
slotName: 'weightCell',
width: 100,
},
{
title: t('order.priceCheck.table.freight'),
dataIndex: 'freight',
slotName: 'freight',
width: 110,
},
{
title: t('order.priceCheck.table.otherFees'),
dataIndex: 'otherFees',
slotName: 'otherFees',
width: 120,
},
{
title: t('order.priceCheck.table.totalCost'),
dataIndex: 'totalCost',
slotName: 'totalCost',
width: 120,
},
{
title: t('order.priceCheck.table.operation'),
slotName: 'operations',
width: 160,
fixed: 'right' as const,
},
]);
/** 序号列占 col 0,数据列从 col 1 起(操作列在末尾,由 HandsontablePro 处理) */
const customCellRenderer = (row: number, col: number, value: any, rowData: any): string | null => {
const dataColIndex = col - 1;
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null;
const colKey = hotColumns.value[dataColIndex].key;
if (colKey === 'shippingMethod') {
let text = rowData?.shippingMethod || '';
const tags: string[] = [];
if (rowData?.isPriceOptimal) {
tags.push(
'<span style="display:inline-block;padding:1px 6px;margin-left:4px;border-radius:2px;font-size:11px;color:#00b42a;background-color:#e8f8e8;">价格最优</span>'
);
function rowClassName(record: any) {
if (record?.isPriceOptimal) return 'price-check-row-optimal-price';
if (record?.isTimeOptimal) return 'price-check-row-optimal-time';
return '';
}
if (rowData?.isTimeOptimal) {
tags.push(
'<span style="display:inline-block;padding:1px 6px;margin-left:4px;border-radius:2px;font-size:11px;color:#165dff;background-color:#e8f3ff;">时效最优</span>'
);
}
return tags.length > 0 ? text + tags.join('') : text;
}
if (colKey === 'cargoTypeName') {
const label = resolveCargoTypeLabel(rowData?.cargoType, value ?? rowData?.cargoTypeName);
return label || null;
}
if (colKey === 'costFormula') {
return value != null && String(value).trim() !== '' ? String(value) : null;
}
if (colKey === 'freight') {
const amount = formatMoneyAmount(value);
if (!amount || Number(amount) === 0) {
return `<span style="color:#bfbfbf;">-</span>`;
}
return `<span style="color:#595959;font-weight:500;">¥${amount}</span>`;
}
if (colKey === 'otherFees') {
const amount = formatMoneyAmount(value);
const tooltip = String(rowData?.otherFeesTooltip ?? '').trim();
if ((!amount || Number(amount) === 0) && !tooltip) {
return `<span style="color:#bfbfbf;">-</span>`;
}
const titleAttr = tooltip
? ` title="${tooltip.replace(/"/g, '&quot;').replace(/\n/g, '&#10;')}"`
: '';
return `<span class="other-fees-cell"${titleAttr}>¥${amount || '0'}</span>`;
}
if (colKey === 'totalCost') {
return value != null ? `<span class="total-cost-cell">¥${value}</span>` : null;
}
return null;
};
const actionRenderer = (row: any, rowIndex: number): string => {
const label = t('order.priceCheck.table.orderNow');
return `<div class="order-action-cell"><a href="javascript:void(0)" class="order-action-link" data-action="order" data-row-index="${rowIndex}">${label}</a></div>`;
};
type SelectOption = { value: string; label: string };
@ -411,8 +414,8 @@
router.push({ name: 'PlaceOrder' });
};
const handleActionClick = (row: any) => {
handleOrder(row);
const handleViewCustomerGuide = (record: any) => {
customerGuideDrawerRef.value?.open(record);
};
const handlePageChange = (page: number) => {
@ -599,24 +602,117 @@
<div ref="tableContainerRef" class="list-wrap">
<a-spin :loading="queryLoading" class="price-check-spin">
<HandsontablePro
:columns="hotColumns"
<a-table
class="price-check-table"
:columns="priceTableColumns"
:data="processedTableData"
:total="totalCount"
:current-page="currentPage"
:page-size="pageSize"
:show-action-column="true"
action-column-position="end"
:action-column-width="120"
:action-renderer="actionRenderer"
:custom-cell-renderer="customCellRenderer"
:height="tableHeight"
:stretch-columns="true"
@action-click="handleActionClick"
/>
:pagination="false"
:bordered="false"
:scroll="{ x: 1100, y: tableHeight }"
:row-class="rowClassName"
row-key="rowKey"
>
<template #shippingMethod="{ record }">
<div class="shipping-method-cell">
<span class="shipping-method-name">{{ record.shippingMethod }}</span>
<div v-if="record.isPriceOptimal || record.isTimeOptimal" class="optimal-tags">
<a-tag v-if="record.isPriceOptimal" size="small" color="green">
{{ t('order.priceCheck.tag.priceOptimal') }}
</a-tag>
<a-tag v-if="record.isTimeOptimal" size="small" color="arcoblue">
{{ t('order.priceCheck.tag.timeOptimal') }}
</a-tag>
</div>
</div>
</template>
<template #deliveryTime="{ record }">
<span class="delivery-time-cell">{{ record.deliveryTime }}</span>
</template>
<template #weightCell="{ record }">
<div class="weight-cell">
<div>
<span class="weight-label">{{ t('order.priceCheck.table.chargeableWeight') }}</span>
{{ record.chargeableWeight ?? '-' }}
</div>
<div class="weight-sub">
<span class="weight-label">{{ t('order.priceCheck.table.volumetricWeight') }}</span>
{{ record.volumetricWeight ?? '-' }}
</div>
</div>
</template>
<template #freight="{ record }">
<span v-if="formatMoneyAmount(record.freight)" class="money-freight">
¥{{ formatMoneyAmount(record.freight) }}
</span>
<span v-else class="money-empty">-</span>
</template>
<template #otherFees="{ record }">
<a-popover
v-if="record.otherFeeList?.length"
position="top"
trigger="hover"
>
<span class="money-other">¥{{ formatMoneyAmount(record.otherFees) || '0' }}</span>
<template #content>
<div class="other-fee-popover">
<div
v-for="(fee, idx) in record.otherFeeList"
:key="idx"
class="other-fee-row"
>
<span>{{ fee.name || fee.costItem }}</span>
<span>¥{{ formatMoneyAmount(fee.amount) }}</span>
</div>
</div>
</template>
</a-popover>
<span v-else-if="formatMoneyAmount(record.otherFees)" class="money-other">
¥{{ formatMoneyAmount(record.otherFees) }}
</span>
<span v-else class="money-empty">-</span>
</template>
<template #totalCost="{ record }">
<a-popover
v-if="record.costFormula"
position="top"
trigger="hover"
>
<span class="money-total money-total-hover">¥{{ formatMoneyAmount(record.totalCost) }}</span>
<template #content>
<div class="cost-formula-popover">
<div class="cost-formula-label">{{ t('order.priceCheck.table.costFormula') }}</div>
<div class="cost-formula-text">{{ record.costFormula }}</div>
</div>
</template>
</a-popover>
<span v-else class="money-total">¥{{ formatMoneyAmount(record.totalCost) }}</span>
</template>
<template #operations="{ record }">
<a-space :size="4">
<a-button type="text" size="small" @click="handleViewCustomerGuide(record)">
{{ t('order.priceCheck.table.viewDetail') }}
</a-button>
<a-button type="text" size="small" class="order-now-btn" @click="handleOrder(record)">
{{ t('order.priceCheck.table.orderNow') }}
</a-button>
</a-space>
</template>
<template #empty>
<a-empty :description="t('order.priceCheck.table.empty')" />
</template>
</a-table>
</a-spin>
</div>
</div>
<CustomerGuideDrawer ref="customerGuideDrawerRef" />
</div>
</template>
@ -812,13 +908,139 @@
width: 100%;
flex: 1;
min-height: 0;
padding: 16px;
padding: 0 16px 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
touch-action: pan-x pan-y;
}
.price-check-table {
:deep(.arco-table-th) {
background: #f7f8fa;
font-weight: 600;
color: #1d2129;
}
:deep(.arco-table-tr) {
transition: background-color 0.2s;
}
:deep(.price-check-row-optimal-price .arco-table-td) {
background: #f6ffed;
}
:deep(.price-check-row-optimal-time:not(.price-check-row-optimal-price) .arco-table-td) {
background: #f0f7ff;
}
}
.shipping-method-cell {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.shipping-method-name {
font-weight: 600;
color: #1d2129;
line-height: 1.4;
}
.optimal-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.delivery-time-cell {
font-weight: 500;
color: #1d2129;
}
.weight-cell {
font-size: 13px;
line-height: 1.5;
color: #4e5969;
}
.weight-label {
color: #86909c;
margin-right: 4px;
}
.weight-sub {
margin-top: 2px;
font-size: 12px;
}
.money-freight {
color: #4e5969;
font-weight: 500;
}
.money-other {
color: var(--app-primary-color);
font-weight: 500;
cursor: help;
border-bottom: 1px dotted var(--app-primary-color);
}
.money-total {
color: #f53f3f;
font-weight: 700;
font-size: 15px;
}
.money-total-hover {
cursor: help;
border-bottom: 1px dotted #f53f3f;
}
.cost-formula-popover {
max-width: 320px;
.cost-formula-label {
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: #86909c;
}
.cost-formula-text {
font-size: 13px;
line-height: 1.6;
color: #1d2129;
white-space: pre-wrap;
word-break: break-word;
}
}
.money-empty {
color: #c9cdd4;
}
.order-now-btn {
color: var(--app-primary-color) !important;
font-weight: 500;
}
.other-fee-popover {
min-width: 180px;
.other-fee-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 4px 0;
font-size: 13px;
line-height: 1.5;
&:not(:last-child) {
border-bottom: 1px dashed #e5e6eb;
}
}
}
.price-check-spin {
@ -842,43 +1064,4 @@
}
}
.list-wrap :deep(.handsontable-pro-container) {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
:deep(.order-action-cell) {
width: 100%;
text-align: center;
}
:deep(.order-action-link) {
color: #165dff;
font-weight: 500;
text-decoration: none;
display: inline-block;
line-height: 22px;
&:hover {
color: #4080ff;
}
}
:deep(.total-cost-cell) {
color: #f53f3f;
font-weight: 600;
font-size: 15px;
}
:deep(.other-fees-cell) {
color: var(--app-primary-color);
font-weight: 500;
cursor: help;
text-decoration: underline dotted;
text-underline-offset: 2px;
}
</style>

View File

@ -141,7 +141,7 @@
</a-form-item>
<a-form-item v-if="formState.type === 1" label="图标" field="icon">
<a-input v-model="formState.icon" placeholder="请输入图标名称" />
<MenuIconPicker v-model="formState.icon" />
</a-form-item>
<a-form-item v-if="formState.type === 1" label="路由" field="route">
@ -190,7 +190,8 @@ import type { MenuDto, MenuTreeDto, MenuCreateDto, MenuUpdateDto, MenuType } fro
import { hasPermission } from '@/utils/rbac-permission'
import { handleApiError } from '@/utils/rbac-error-handler'
import { syncMenuLevel3Buttons } from '@/utils/syncMenuLevel3Buttons'
import * as Icons from '@arco-design/web-vue/es/icon'
import MenuIconPicker from '@/components/MenuIconPicker/index.vue'
import { resolveArcoIcon, toPascalIconName } from '@/utils/menu-icon'
const { t } = useI18n()
@ -340,7 +341,7 @@ function cleanMenuData(data: MenuCreateDto): MenuCreateDto {
name: data.name,
type: data.type,
permissionCode: data.permissionCode && data.permissionCode.trim() ? data.permissionCode : undefined,
icon: data.icon && data.icon.trim() ? data.icon : undefined,
icon: data.icon && data.icon.trim() ? toPascalIconName(data.icon.trim()) : undefined,
route: data.route && data.route.trim() ? data.route : undefined,
component: data.component && data.component.trim() ? data.component : undefined,
order: data.order ?? 0,
@ -359,7 +360,7 @@ async function handleModalOk() {
const updateData: MenuUpdateDto = {
name: formState.name,
permissionCode: formState.permissionCode || undefined,
icon: formState.icon || undefined,
icon: formState.icon ? toPascalIconName(formState.icon.trim()) : undefined,
route: formState.route || undefined,
component: formState.component || undefined,
isHidden: formState.isHidden,
@ -433,7 +434,7 @@ function handleSyncLevel3Buttons(record: MenuTreeDto) {
}
function getIcon(iconName: string) {
return (Icons as any)[iconName] || null
return resolveArcoIcon(iconName)
}

View File

@ -301,7 +301,7 @@ function getUserRoleCodes(u: any): string[] {
.filter(Boolean)
}
/** 客户门户账号(Customer / CustomerSub_* 角色)不可作为销售或客服 */
/** 客户门户账号(Customer / CustomerSub_* 角色)不可作为销售或顾问 */
function isCustomerPortalUser(u: any, excludedUserIds?: Set<string>): boolean {
const id = String(u?.id ?? u?.Id ?? '')
if (id && excludedUserIds?.has(id)) return true

View File

@ -244,7 +244,7 @@ function getMockProfile(): CustomerDto {
createdAt: '2024-01-15 10:30:00',
createdBy: '管理员',
salesPerson: '销售A',
customerService: '客服甲',
customerService: '顾问甲',
lastOnlineTime: new Date().toLocaleString('zh-CN'),
lastOnlineIp: '192.168.1.100',
}