front/src/components/MenuIconPicker/index.vue
zzh 1a5e799ce2 feat: 订单指引流程图、对客说明与查价/菜单体验优化
新增订单与渠道新手指引(Mermaid 流程图),销售产品对客说明 Tab,查价表格与详情抽屉,菜单图标可视化选择;MarkdownViewer 支持 Mermaid 渲染。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 15:17:33 +08:00

326 lines
7.8 KiB
Vue

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