新增订单与渠道新手指引(Mermaid 流程图),销售产品对客说明 Tab,查价表格与详情抽屉,菜单图标可视化选择;MarkdownViewer 支持 Mermaid 渲染。 Co-authored-by: Cursor <cursoragent@cursor.com>
326 lines
7.8 KiB
Vue
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>
|