Files
CarLog/client/src/components/ChemPicker.vue
T
wsh5485 fe17886ac4 feat: 洗车管理系统 v2.8 — 个人 detailer 单用户全栈应用
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 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
2026-06-20 21:11:54 +08:00

190 lines
6.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>