fe17886ac4
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 CRUD + 软删 - AI 截图识别(5 类型 OCR schema):OpenAI 兼容 + MiniMax M3 - 化学品 / Grocy 实例对接 + 库存镜像同步 - 仪表盘:30 天频次 + 健康度 + 同比环比 + 油价趋势 + 年均养护 - 月度报表:Excel 6 sheet + PDF - PWA:manifest / SW / 离线缓存 / iOS 引导 - 安全:bcrypt + CSRF + 登录锁定(IP/用户/全局三级)+ 401 自动跳登录 + 表单草稿 - 高 ROI 8 功能:里程/提醒/成本/搜索/标签/通知/同比/成就 - 3 个新 migration(0016/0017/0018)+ 18 个迁移全幂等 - 101/101 测试通过(含 ipRateLimit / CSRF / retry / stats / tags / notifications) - 部署:宝塔面板文档 + PM2 + Nginx
190 lines
6.3 KiB
Vue
190 lines
6.3 KiB
Vue
<template>
|
||
<div class="chem-picker" ref="rootEl">
|
||
<!-- 搜索框(tag + input 同一行) -->
|
||
<div class="search-wrap">
|
||
<!-- 已选标签 -->
|
||
<span v-if="selected.length > 0" class="tag">
|
||
{{ selected[0].name }}
|
||
<button type="button" class="tag-x" @click.stop="clearSelected">×</button>
|
||
</span>
|
||
<!-- 输入框 -->
|
||
<input
|
||
ref="inputEl"
|
||
:value="query"
|
||
class="input chem-search"
|
||
:placeholder="selected.length > 0 ? '' : placeholder"
|
||
autocomplete="off"
|
||
@input="query = $event.target.value"
|
||
@focus="open = true"
|
||
@keydown="onKeydown"
|
||
/>
|
||
<!-- 下拉 -->
|
||
<div v-if="open && filtered.length > 0" class="dropdown">
|
||
<div
|
||
v-for="(ch, idx) in filtered"
|
||
:key="ch.grocy_product_id"
|
||
class="item"
|
||
:class="{ active: idx === activeIdx }"
|
||
@click="add(ch)"
|
||
@mouseover="activeIdx = idx"
|
||
>
|
||
<span class="item-name">{{ ch.name }}</span>
|
||
<span class="item-meta">
|
||
{{ ch.category || '—' }}
|
||
<span class="item-stock">库存 {{ ch.current_amount }} {{ ch.unit || '' }}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="open && query && filtered.length === 0" class="dropdown empty">
|
||
无匹配「{{ query }}」
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||
|
||
const props = defineProps({
|
||
modelValue: { type: String, default: '' }, // 当前选中 id
|
||
chemicals: { type: Array, default: () => [] },
|
||
placeholder: { type: String, default: '搜索化学品…' },
|
||
});
|
||
const emit = defineEmits(['update:modelValue', 'change']);
|
||
|
||
// 当前已选标签(从 chemicals 解析)
|
||
const selected = computed(() =>
|
||
props.chemicals.filter(c => c.grocy_product_id === props.modelValue)
|
||
);
|
||
|
||
const query = ref('');
|
||
const open = ref(false);
|
||
const activeIdx = ref(0);
|
||
const rootEl = ref(null);
|
||
const inputEl = ref(null);
|
||
|
||
// 搜索框显示:选中项显示名字,无选中时显示用户输入
|
||
const inputDisplay = computed(() => {
|
||
if (selected.value.length > 0) return selected.value[0].name;
|
||
return query.value;
|
||
});
|
||
|
||
// 模糊搜索:匹配 name 和 category
|
||
const filtered = computed(() => {
|
||
const q = query.value.trim().toLowerCase();
|
||
if (!q) return props.chemicals.slice(0, 30); // 无关键词显示前30个
|
||
return props.chemicals
|
||
.filter(c =>
|
||
c.name.toLowerCase().includes(q) ||
|
||
(c.category || '').toLowerCase().includes(q)
|
||
)
|
||
.slice(0, 50); // 最多50条
|
||
});
|
||
|
||
watch(query, () => { activeIdx.value = 0; });
|
||
watch(open, (v) => { if (v) nextTick(() => inputEl.value?.focus()); });
|
||
|
||
function add(ch) {
|
||
emit('update:modelValue', ch.grocy_product_id);
|
||
emit('change', ch);
|
||
query.value = '';
|
||
open.value = false;
|
||
nextTick(() => inputEl.value?.focus());
|
||
}
|
||
|
||
function remove(id) {
|
||
if (id === props.modelValue) {
|
||
emit('update:modelValue', '');
|
||
emit('change', null);
|
||
}
|
||
}
|
||
|
||
function clearSelected() {
|
||
emit('update:modelValue', '');
|
||
emit('change', null);
|
||
query.value = '';
|
||
open.value = false;
|
||
nextTick(() => inputEl.value?.focus());
|
||
}
|
||
|
||
function onKeydown(e) {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
activeIdx.value = Math.min(activeIdx.value + 1, filtered.value.length - 1);
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
activeIdx.value = Math.max(activeIdx.value - 1, 0);
|
||
} else if (e.key === 'Enter' && filtered.value.length > 0) {
|
||
e.preventDefault();
|
||
add(filtered.value[activeIdx.value]);
|
||
} else if (e.key === 'Escape') {
|
||
open.value = false;
|
||
} else if (e.key === 'Backspace' && !query.value && props.modelValue) {
|
||
emit('update:modelValue', '');
|
||
emit('change', null);
|
||
}
|
||
}
|
||
|
||
// 点击外部关闭
|
||
function onDocClick(e) {
|
||
if (rootEl.value && !rootEl.value.contains(e.target)) {
|
||
open.value = false;
|
||
}
|
||
}
|
||
onMounted(() => document.addEventListener('click', onDocClick));
|
||
onUnmounted(() => document.removeEventListener('click', onDocClick));
|
||
</script>
|
||
|
||
<style scoped>
|
||
.chem-picker { position: relative; }
|
||
.search-wrap {
|
||
position: relative;
|
||
display: flex; align-items: center; gap: 4px;
|
||
min-height: 36px; /* 固定高度 = 跟其他 input 一致 */
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--bg);
|
||
padding: 2px 8px;
|
||
}
|
||
.search-wrap:focus-within {
|
||
border-color: var(--brand);
|
||
box-shadow: 0 0 0 2px rgba(30, 91, 138, 0.15);
|
||
}
|
||
.tag {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
background: var(--brand); color: #fff;
|
||
padding: 2px 6px 2px 8px; border-radius: 12px;
|
||
font-size: 12px; white-space: nowrap; flex-shrink: 0;
|
||
}
|
||
.tag-x {
|
||
background: none; border: none; color: inherit;
|
||
cursor: pointer; padding: 0; line-height: 1;
|
||
font-size: 14px; opacity: 0.7;
|
||
}
|
||
.tag-x:hover { opacity: 1; }
|
||
.chem-search {
|
||
flex: 1; min-width: 80px;
|
||
border: none; outline: none; background: transparent;
|
||
font-size: 14px; color: var(--text);
|
||
padding: 4px 0;
|
||
}
|
||
.chem-search::placeholder { color: var(--text-soft); }
|
||
.dropdown {
|
||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||
background: var(--bg); border: 1px solid var(--border);
|
||
border-radius: var(--radius); box-shadow: 0 4px 12px rgba(0,0,0,.1);
|
||
z-index: 200; max-height: 320px; overflow-y: auto;
|
||
}
|
||
.dropdown.empty { padding: 8px 12px; color: var(--text-soft); font-size: 13px; }
|
||
.item {
|
||
padding: 8px 12px; cursor: pointer;
|
||
border-bottom: 1px solid var(--border); font-size: 13px;
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
}
|
||
.item:last-child { border-bottom: none; }
|
||
.item:hover, .item.active { background: var(--bg-soft); }
|
||
.item-name { font-weight: 500; }
|
||
.item-meta { font-size: 12px; color: var(--text-soft); display: flex; gap: 8px; align-items: center; }
|
||
.item-stock { color: var(--brand); }
|
||
</style>
|