Appearance
CpAutoComplete 自动补全
对齐旧版 AutoComplete-1.0.1:下拉为多列表格形态(默认 代码 | 名称 两列,可通过 columns 扩展任意列),下拉顶部带列标题表头(show-header 可关),匹配关键词红色加粗高亮,值取 Code,所有列字段大小写不敏感模糊匹配。
基础用法
基础用法
输入 sha 匹配代码 CNSHA(命中段红色加粗),输入「宁波」匹配名称;下拉为带表头的表格形态,列多时可对照标题。
<template>
<div class="demo-row">
<CpAutoComplete v-model="port" :options="PORTS" style="width: 260px" />
<span class="demo-value">值: {{ port || "-" }}</span>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { PORTS } from "../ports";
const port = ref("");
</script>显示代码 ▼
多选回填 + 搜索弹窗
多选回填 + 搜索弹窗
multiple 开启多选:选中值以逗号拼接,输入分隔符后按最后一段继续补全(旧版 delimiter 契约);dialog 显示放大镜按钮,唤起表格弹窗——打开时按输入框已有值自动勾选,弹窗内输入即自动搜索(es-toolkit 防抖,dialog-auto-search 可关),搜索到与关键词完全相等的行自动勾选,可连续搜索累积,确认后回填为 CAOZUO,cargoplus 形态(对应旧版「选中」弹窗)。<template>
<div class="demo-col">
<div class="demo-row">
<CpAutoComplete
v-model="operators"
:options="OPERATORS"
multiple
dialog
dialog-title="选中"
placeholder="输入或点放大镜多选"
style="width: 300px"
@confirm="(rows) => (msg = `弹窗勾选 ${rows.length} 项`)"
/>
<span class="demo-value">值: {{ operators || "-" }}</span>
</div>
<span class="demo-tip">{{
msg ||
"输入框已有 MIKE,点放大镜打开弹窗可看到已自动勾选;弹窗内输入即自动搜索(防抖),搜到完整代码(如 KELLY)自动勾选,可连续累积后确认回填"
}}</span>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const OPERATORS = [
{ Code: "CAOZUO", Name: "操作" },
{ Code: "cargoplus", Name: "CARGOPLUS" },
{ Code: "KELLY", Name: "KELLY" },
{ Code: "LUNA01-OP", Name: "LUNA01" },
{ Code: "MARY.SA", Name: "MARY.SA" },
{ Code: "Mickey.SZX", Name: "Mickey" },
{ Code: "MIKE", Name: "MICHEAL" },
{ Code: "OP1.SZX", Name: "深圳操作显示名1" },
];
const operators = ref("MIKE");
const msg = ref("");
</script>显示代码 ▼
远程检索与请求竞态
远程数据源用 load 接入,签名 (query, ctx) => Promise<rows>。组件对连续输入做了两层防护:
- 取消上一个请求 —
ctx.signal是标准AbortSignal,透传给 fetch / axios / alova 即可 - 丢弃过期响应 — 按序号比对,只有最后一次输入的结果会进下拉
第 2 条才是正确性保证:abort() 只是尽力而为,响应若已在回程路上是拦不住的,此时仍要靠序号守卫把旧结果扔掉。所以即使请求库不支持取消,这个组件也不会出现旧结果覆盖新结果。
请求节流沿用 el-autocomplete 自带的 debounce(默认 300ms),按需透传覆盖。
远程检索与请求竞态
左侧裸用 el-autocomplete 作对照:快速连续输入时,先发出的短关键词最后返回,把最新结果盖掉。右侧 CpAutoComplete 把 ctx.signal 交给 alova,旧请求被取消,漏网的过期响应也会被丢弃。
<template>
<div class="ac-remote">
<p class="ac-remote__tip">
假接口刻意<strong>关键词越长回得越快</strong>(1200ms 起,每多一字符快
350ms),且已关闭输入防抖便于逐字符观察。请连续输入 <code>C</code> →
<code>N</code> → <code>S</code>,看两侧下拉最终显示的是不是 CNS 的结果。
</p>
<div class="ac-remote__cols">
<div class="ac-remote__col">
<div class="ac-remote__label is-bad">✗ 裸用 el-autocomplete</div>
<el-autocomplete
v-model="naive"
:fetch-suggestions="naiveFetch"
:debounce="0"
value-key="Code"
placeholder="输入港口代码"
style="width: 100%"
>
<template #default="{ item }">
<span>{{ item.Code }} — {{ item.Name }}</span>
</template>
</el-autocomplete>
<div class="ac-remote__logs">
<div v-if="!naiveLogs.length" class="ac-remote__empty">暂无请求</div>
<div
v-for="l in naiveLogs"
:key="l.id"
class="ac-remote__log"
:class="`is-${l.state}`"
>
<span class="ac-remote__kw">{{ l.query }}</span>
<span class="ac-remote__delay">{{ l.delay }}ms</span>
<span class="ac-remote__state">{{ STATE_TEXT[l.state] }}</span>
</div>
</div>
<div class="ac-remote__result">
下拉最终显示:<b>{{ naiveShown || "-" }}</b>
</div>
</div>
<div class="ac-remote__col">
<div class="ac-remote__label is-good">✓ CpAutoComplete + alova</div>
<CpAutoComplete
v-model="guarded"
:load="load"
:debounce="0"
placeholder="输入港口代码"
style="width: 100%"
@suggestions="guardedShown = summarize($event)"
/>
<div class="ac-remote__logs">
<div v-if="!guardedLogs.length" class="ac-remote__empty">
暂无请求
</div>
<div
v-for="l in guardedLogs"
:key="l.id"
class="ac-remote__log"
:class="`is-${l.state}`"
>
<span class="ac-remote__kw">{{ l.query }}</span>
<span class="ac-remote__delay">{{ l.delay }}ms</span>
<span class="ac-remote__state">{{ STATE_TEXT[l.state] }}</span>
</div>
</div>
<div class="ac-remote__result">
下拉最终显示:<b>{{ guardedShown || "-" }}</b>
</div>
</div>
</div>
<div class="ac-remote__ops">
<el-button size="small" type="primary" :loading="racing" @click="race">
一键复现:依次检索 C → CN → CNS
</el-button>
<el-button size="small" @click="reset">清空</el-button>
<span class="ac-remote__hint">
两侧发出的请求完全相同,只是左侧不做并发处理、右侧走
<code>createLatestRequest</code>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import {
createLatestRequest,
type CpLoader,
} from "../../src/utils/latestRequest";
import { clearLogs, reqLogs, searchPorts } from "../api/portApi";
const STATE_TEXT = {
pending: "请求中",
aborted: "已取消",
done: "已返回",
} as const;
const naive = ref("");
const guarded = ref("");
/** 记录两侧下拉「实际收到」的结果,用于对比是否被旧响应污染 */
const naiveShown = ref("");
const guardedShown = ref("");
const naiveLogs = computed(() => reqLogs.filter((l) => l.owner === "naive"));
const guardedLogs = computed(() =>
reqLogs.filter((l) => l.owner === "guarded"),
);
const summarize = (rows: Record<string, any>[]) =>
rows.length ? rows.map((r) => r.Code).join(" / ") : "无匹配";
/**
* ✓ 正确写法:把 ctx.signal 交给 alova,上一次请求随新输入被 abort;
* 即便 abort 追不上,组件也会按序号丢弃过期响应。
*
* 注意 load 里不写任何副作用 —— 它在并发守卫之前执行,过期请求同样会跑到。
* 展示用的 guardedShown 挂在 @suggestions 上,那才是「结果已被采纳」的时点。
*/
const load: CpLoader = (query, { signal }) =>
searchPorts(query, signal, "guarded");
/**
* ✗ 对照:不传取消信号、不判断响应是否过期,谁后返回谁说了算。
* 快速输入时先发的短关键词最后返回,会把最新结果盖掉。
*/
async function naiveFetch(
query: string,
cb: (rows: Record<string, any>[]) => void,
) {
const rows = await searchPorts(query, new AbortController().signal, "naive");
naiveShown.value = summarize(rows);
cb(rows);
}
// ==================== 一键复现(不依赖手速的确定性对照) ====================
const racing = ref(false);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** 守卫可以脱离组件单独用,业务里自己写下拉时同理 */
const raceGuard = createLatestRequest<Record<string, any>[]>();
async function race() {
reset();
racing.value = true;
const queries = ["C", "CN", "CNS"];
// 左:每个响应回来就直接用,最后返回的赢
const naiveRun = async () => {
for (const q of queries) {
searchPorts(q, new AbortController().signal, "naive").then((rows) => {
naiveShown.value = summarize(rows);
});
await sleep(200);
}
};
// 右:同样三个请求,交给守卫裁决
const guardedRun = async () => {
for (const q of queries) {
raceGuard.run((ctx) => searchPorts(q, ctx.signal, "guarded")).then((res) => {
if (!res.stale) guardedShown.value = summarize(res.data);
});
await sleep(200);
}
};
await Promise.all([naiveRun(), guardedRun()]);
await sleep(1400); // 等最慢的 C 也回来,看它会不会翻盘
racing.value = false;
}
function reset() {
clearLogs();
naiveShown.value = "";
guardedShown.value = "";
}
</script>
<style scoped>
.ac-remote__tip {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.7;
color: var(--el-text-color-regular);
}
.ac-remote__cols {
display: flex;
gap: 16px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.ac-remote__col {
flex: 1;
min-width: 240px;
}
.ac-remote__label {
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.ac-remote__label.is-bad {
color: #f56c6c;
}
.ac-remote__label.is-good {
color: #67c23a;
}
.ac-remote__logs {
margin-top: 8px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
min-height: 92px;
font-size: 12px;
font-family: var(--vp-font-family-mono, monospace);
}
.ac-remote__empty {
padding: 8px;
color: var(--el-text-color-placeholder);
}
.ac-remote__log {
display: flex;
gap: 8px;
padding: 4px 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.ac-remote__log:last-child {
border-bottom: none;
}
.ac-remote__kw {
flex: 1;
font-weight: 600;
}
.ac-remote__delay {
color: var(--el-text-color-secondary);
}
.ac-remote__state {
width: 48px;
text-align: right;
}
.ac-remote__log.is-pending .ac-remote__state {
color: var(--el-text-color-secondary);
}
.ac-remote__log.is-aborted .ac-remote__state {
color: #e6a23c;
}
.ac-remote__log.is-done .ac-remote__state {
color: #67c23a;
}
.ac-remote__ops {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.ac-remote__hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.ac-remote__result {
margin-top: 8px;
font-size: 13px;
color: var(--el-text-color-regular);
}
</style>显示代码 ▼
请求库放在业务侧
组件只约定 load 契约,不绑定任何请求库 —— 你现有的鉴权、拦截器、错误处理照常复用。 demo 用的是 alova,桥接代码就一行:signal.addEventListener("abort", () => method.abort())。
API
属性 Props
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
v-model | — | string | "" |
options | — | Record[] | — |
load | 远程数据源。第二参数 ctx.signal 透传给请求库即可让上一次输入的请求被取消; 即便不取消,组件也会丢弃过期响应,不会出现旧结果覆盖新结果。 请求节流由 el-autocomplete 的 debounce 属性控制(默认 300ms,可透传覆盖)。 | CpLoader | — |
code-field | — | string | "Code" |
name-field | — | string | "Name" |
columns | 下拉/弹窗表格列;默认 [codeField(110px), nameField] | CpAutoColumn[] | — |
placeholder | — | string | — |
multiple | 多选回填:选中追加而非替换,值以 delimiter 拼接 | boolean | — |
delimiter | 多值分隔符(对应旧版 delimiter 选项) | string | "," |
dialog | 显示输入框内搜索按钮,点击唤起选择弹窗 | boolean | — |
dialog-title | — | string | "选中" |
dialog-auto-search | 弹窗搜索框输入即自动检索(防抖 300ms),false 时需回车或点检索按钮 | boolean | true |
show-header | 下拉顶部显示列标题表头(多列时标识每列含义) | boolean | true |
事件 Events
| 事件 | 说明 | 回调参数 |
|---|---|---|
@select | — | (Record) |
@confirm | 弹窗确认(多选时为全部勾选行) | (Array) |
@suggestions | 下拉建议已更新(过期响应不会触发)。 远程模式下的副作用请挂这里 —— 写在 load 内部的副作用发生在并发守卫之前, 过期请求同样会执行,导致界面被旧结果写脏。 | (Array) |