diff --git a/resources/ai-kb/zh/howto/user-account/search.md b/resources/ai-kb/zh/howto/user-account/search.md
index db4f550b1..d59e4ea81 100644
--- a/resources/ai-kb/zh/howto/user-account/search.md
+++ b/resources/ai-kb/zh/howto/user-account/search.md
@@ -20,7 +20,7 @@ negative:
- 默认排除机器人(bot=0),需要机器人时 bot=1/2
- 单次最多返回 100 条,超过需翻页(page + pagesize)
- 搜索结果只含基础字段(basicField),完整资料需调 user/info 或 get_users_basic
-last_verified: v1.7.90
+last_verified: v1.9.18
---
# 搜索用户
@@ -37,6 +37,12 @@ last_verified: v1.7.90
- 纯数字 → 同时按 userid 精确 + nickname / pinyin / profession 模糊
- 其它 → 按 nickname / pinyin / profession 模糊
+## 取消已选成员
+- 人员选择弹窗搜索框左侧显示已选头像。鼠标悬停头像后,点击右上角的关闭图标取消选择;鼠标点击头像本身不会取消。
+- 触摸操作直接轻点已选头像取消选择。禁止取消的成员不会显示关闭图标,触摸点击也不会取消。
+- 搜索框为空时按 Backspace,取消末尾第一个允许取消的成员,并将已选列表滚动到最右端;没有可取消成员时保持不变。
+- 关闭图标支持键盘聚焦后按 Enter 或空格取消选择。修改选择后仍需点击「确定」提交。
+
## 高级筛选
- `keys.disable`:`0` 仅在职(默认)/ `1` 仅离职 / `2` 全部
- `keys.bot`:`0` 排除机器人(默认)/ `1` 仅机器人 / `2` 全部
diff --git a/resources/assets/js/components/UserSelect.vue b/resources/assets/js/components/UserSelect.vue
index 6aac1ac8b..fa39d1019 100755
--- a/resources/assets/js/components/UserSelect.vue
+++ b/resources/assets/js/components/UserSelect.vue
@@ -44,8 +44,16 @@
-
- -
+
+ -
@@ -55,6 +63,15 @@
+
@@ -340,6 +357,7 @@ export default {
waitIng: 0, // 页面等待效果
submittIng: 0, // 提交按钮等待效果
backspaceDelete: false, // 是否按删除键删除
+ selectedPointerType: 'mouse',
values: [],
selects: [],
@@ -885,6 +903,13 @@ export default {
this.onSubmit()
},
+ onSelectedAvatarClick(event, userid) {
+ const pointerType = event.pointerType || this.selectedPointerType;
+ if (pointerType === 'touch' || pointerType === 'pen') {
+ this.onRemoveItem(userid);
+ }
+ },
+
onRemoveItem(userid) {
if (this.isUncancelable(userid)) {
return
@@ -955,6 +980,13 @@ export default {
const userid = this.selects[i];
if (!this.isUncancelable(userid)) {
this.onRemoveItem(userid);
+ this.$nextTick(() => {
+ const selected = this.$refs.selected;
+ const element = selected && selected.scrollElement();
+ if (element) {
+ element.scrollLeft = element.scrollWidth;
+ }
+ });
break; // 找到并移除后立即退出循环
}
}
diff --git a/resources/assets/sass/components/user-select.scss b/resources/assets/sass/components/user-select.scss
index 7879a1939..bbd85720e 100755
--- a/resources/assets/sass/components/user-select.scss
+++ b/resources/assets/sass/components/user-select.scss
@@ -108,7 +108,7 @@
flex-shrink: 0;
display: flex;
align-items: center;
- margin: 0 24px;
+ margin: -6px 24px;
.search-selected {
flex: none;
@@ -122,10 +122,58 @@
align-items: center;
cursor: pointer;
+ &.selected-avatar-list {
+ padding: 10px 0;
+ cursor: default;
+
+ > li {
+ position: relative;
+ flex-shrink: 0;
+ }
+ }
+
> li {
list-style: none;
margin-right: 6px;
+ &.selected-removable:not(.selected-mouse) {
+ cursor: pointer;
+ }
+
+ .selected-remove {
+ position: absolute;
+ top: -6px;
+ right: -4px;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ padding: 0;
+ border: 1px solid #ffffff;
+ border-radius: 50%;
+ background: #ed4014;
+ color: #ffffff;
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+ opacity: 0;
+ pointer-events: none;
+
+ &:focus-visible {
+ opacity: 1;
+ pointer-events: auto;
+ outline: 2px solid #2d8cf0;
+ outline-offset: 1px;
+ }
+ }
+
+ &.selected-mouse:hover .selected-remove {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
&:last-child {
margin-right: 0;
}
diff --git a/tests/user-select.test.mjs b/tests/user-select.test.mjs
new file mode 100644
index 000000000..02d9ddf38
--- /dev/null
+++ b/tests/user-select.test.mjs
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import test from 'node:test';
+import compiler from 'vue-template-compiler';
+
+const source = readFileSync(new URL('../resources/assets/js/components/UserSelect.vue', import.meta.url), 'utf8');
+const component = compiler.parseComponent(source);
+const options = new Function('mapState', component.script.content
+ .replace(/import .*?;\n/g, '')
+ .replace('export default', 'return'))(() => ({}));
+
+function fixture(selects = [1, 2, 3], uncancelable = []) {
+ const element = {scrollLeft: 0, scrollWidth: 500};
+ const ticks = [];
+ const vm = {
+ ...options.data(), selects, uncancelable,
+ $refs: {selected: {scrollElement: () => element}},
+ $nextTick: fn => ticks.push(fn),
+ };
+ for (const [key, method] of Object.entries(options.methods)) vm[key] = method.bind(vm);
+ return {vm, element, flush: () => ticks.splice(0).forEach(fn => fn())};
+}
+
+test('template compiles and removal button is guarded and stops click propagation', () => {
+ assert.deepEqual(compiler.compile(component.template.content).errors, []);
+ assert.match(component.template.content, /v-if="!isUncancelable\(item.userid\)"[\s\S]*?@click.stop="onRemoveItem\(item.userid\)"/);
+});
+
+test('mouse avatar clicks do not remove; touch and pen do; fallback follows last input', () => {
+ const {vm} = fixture();
+ vm.onSelectedAvatarClick({pointerType: 'mouse'}, 1);
+ assert.deepEqual(vm.selects, [1, 2, 3]);
+ vm.onSelectedAvatarClick({pointerType: 'touch'}, 1);
+ vm.onSelectedAvatarClick({pointerType: 'pen'}, 2);
+ vm.selectedPointerType = 'touch';
+ vm.onSelectedAvatarClick({}, 3);
+ assert.deepEqual(vm.selects, []);
+});
+
+test('uncancelable members survive both avatar and direct removal', () => {
+ const {vm} = fixture([1, 2], [2]);
+ vm.onSelectedAvatarClick({pointerType: 'touch'}, 2);
+ vm.onRemoveItem(2);
+ vm.onRemoveItem(1);
+ assert.deepEqual(vm.selects, [2]);
+});
+
+test('Backspace skips locked tail and scrolls to the updated end after rendering', () => {
+ const {vm, element, flush} = fixture([1, 2, 3], [3]);
+ vm.onKeydown({key: 'Backspace'});
+ vm.onKeyup({key: 'Backspace'});
+ assert.deepEqual(vm.selects, [1, 3]);
+ assert.equal(element.scrollLeft, 0);
+ element.scrollWidth = 380;
+ flush();
+ assert.equal(element.scrollLeft, 380);
+});
+
+test('Backspace does not remove while searching, composing, or when all members are locked', () => {
+ for (const kind of ['search', 'composition', 'locked']) {
+ const {vm, element, flush} = fixture([1], kind === 'locked' ? [1] : []);
+ if (kind === 'search') vm.searchKey = 'a';
+ const event = {key: 'Backspace', isComposing: kind === 'composition'};
+ vm.onKeydown(event);
+ vm.onKeyup(event);
+ flush();
+ assert.deepEqual(vm.selects, [1]);
+ assert.equal(element.scrollLeft, 0);
+ }
+});
+
+test('deleting the final member or closing before next tick safely skips scrolling', () => {
+ const {vm, flush} = fixture([1]);
+ vm.onKeydown({key: 'Backspace'});
+ vm.onKeyup({key: 'Backspace'});
+ assert.deepEqual(vm.selects, []);
+ delete vm.$refs.selected;
+ assert.doesNotThrow(flush);
+});