|
|
@@ -23,11 +23,8 @@
|
|
|
:key="item.id"
|
|
|
:session="item"
|
|
|
:active="item.id === activeId"
|
|
|
- :editing="editingId === item.id"
|
|
|
@select="emit('select', item.id)"
|
|
|
- @start-rename="startRename(item.id, item.title)"
|
|
|
- @commit-rename="(t) => commitRename(item.id, t)"
|
|
|
- @cancel-rename="cancelRename"
|
|
|
+ @rename="startRename(item.id, item.title)"
|
|
|
@pin="emit('pin', item.id)"
|
|
|
@remove="emit('remove', item.id)"
|
|
|
/>
|
|
|
@@ -40,11 +37,8 @@
|
|
|
:key="item.id"
|
|
|
:session="item"
|
|
|
:active="item.id === activeId"
|
|
|
- :editing="editingId === item.id"
|
|
|
@select="emit('select', item.id)"
|
|
|
- @start-rename="startRename(item.id, item.title)"
|
|
|
- @commit-rename="(t) => commitRename(item.id, t)"
|
|
|
- @cancel-rename="cancelRename"
|
|
|
+ @rename="startRename(item.id, item.title)"
|
|
|
@pin="emit('pin', item.id)"
|
|
|
@remove="emit('remove', item.id)"
|
|
|
/>
|
|
|
@@ -55,6 +49,28 @@
|
|
|
</div>
|
|
|
</template>
|
|
|
</div>
|
|
|
+
|
|
|
+ <!-- 重命名:统一走弹窗,不再做行内编辑(行内输入在窄侧栏里不好点、也容易误触) -->
|
|
|
+ <Modal
|
|
|
+ v-model:open="renameVisible"
|
|
|
+ title="重命名会话"
|
|
|
+ :width="420"
|
|
|
+ ok-text="保存"
|
|
|
+ cancel-text="取消"
|
|
|
+ :ok-button-props="{ disabled: !canRename }"
|
|
|
+ @ok="handleRenameOk"
|
|
|
+ @cancel="handleRenameCancel"
|
|
|
+ >
|
|
|
+ <a-input
|
|
|
+ ref="renameInputRef"
|
|
|
+ v-model:value="renameDraft"
|
|
|
+ :maxlength="TITLE_MAX_LENGTH"
|
|
|
+ allow-clear
|
|
|
+ placeholder="请输入会话标题"
|
|
|
+ @press-enter="handleRenameOk"
|
|
|
+ />
|
|
|
+ <div class="ai-rename__count">{{ renameDraft.length }} / {{ TITLE_MAX_LENGTH }}</div>
|
|
|
+ </Modal>
|
|
|
</aside>
|
|
|
</template>
|
|
|
|
|
|
@@ -65,62 +81,35 @@
|
|
|
* 搜索在前端做(会话量级为个人级,无需服务端分页),
|
|
|
* 置顶会话单独成组,其余按最近消息时间排序。
|
|
|
*/
|
|
|
- import { computed, defineComponent, h, ref, watch } from 'vue';
|
|
|
- import { Button, Input, Modal } from 'ant-design-vue';
|
|
|
+ import { computed, defineComponent, h, nextTick, ref, watch } from 'vue';
|
|
|
+ import { Modal } from 'ant-design-vue';
|
|
|
import Icon from '@/core/components/Icon/src/Icon.vue';
|
|
|
import type { ChatSession } from '../api/types';
|
|
|
import { fromNow, sessionTitle as resolveTitle } from '../utils/parseMessageContent';
|
|
|
|
|
|
- const AInput = Input;
|
|
|
- const AButton = Button;
|
|
|
-
|
|
|
- /** 单行会话(内联重命名),拆成局部组件避免列表模板过长 */
|
|
|
+ /** 单行会话;重命名由父级弹窗处理,这里只负责派发意图 */
|
|
|
const SessionRow = defineComponent({
|
|
|
name: 'AiSessionRow',
|
|
|
props: {
|
|
|
session: { type: Object as () => ChatSession, required: true },
|
|
|
active: { type: Boolean, default: false },
|
|
|
- editing: { type: Boolean, default: false },
|
|
|
},
|
|
|
- emits: ['select', 'start-rename', 'commit-rename', 'cancel-rename', 'pin', 'remove'],
|
|
|
+ emits: ['select', 'rename', 'pin', 'remove'],
|
|
|
setup(props, { emit }) {
|
|
|
- const draft = ref('');
|
|
|
- watch(
|
|
|
- () => props.editing,
|
|
|
- (v) => {
|
|
|
- if (v) draft.value = props.session.title || '';
|
|
|
- },
|
|
|
- { immediate: true },
|
|
|
- );
|
|
|
-
|
|
|
return () => {
|
|
|
const s = props.session;
|
|
|
const title = resolveTitle(s);
|
|
|
const meta = fromNow(s.lastMessageAt || s.updateAt || s.createAt);
|
|
|
|
|
|
- const titleNode = props.editing
|
|
|
- ? h(Input, {
|
|
|
- value: draft.value,
|
|
|
- size: 'small',
|
|
|
- autofocus: true,
|
|
|
- 'onUpdate:value': (v: string) => (draft.value = v),
|
|
|
- onClick: (e: Event) => e.stopPropagation(),
|
|
|
- onPressEnter: () => emit('commit-rename', draft.value.trim() || title),
|
|
|
- onKeydown: (e: KeyboardEvent) => {
|
|
|
- if (e.key === 'Escape') emit('cancel-rename');
|
|
|
- },
|
|
|
- })
|
|
|
- : h('span', { class: 'ai-session__title', title }, title);
|
|
|
-
|
|
|
return h(
|
|
|
'div',
|
|
|
{
|
|
|
- class: ['ai-session', { 'is-active': props.active, 'is-editing': props.editing }],
|
|
|
- onClick: () => !props.editing && emit('select'),
|
|
|
+ class: ['ai-session', { 'is-active': props.active }],
|
|
|
+ onClick: () => emit('select'),
|
|
|
},
|
|
|
[
|
|
|
h('div', { class: 'ai-session__main' }, [
|
|
|
- titleNode,
|
|
|
+ h('span', { class: 'ai-session__title', title }, title),
|
|
|
h('div', { class: 'ai-session__meta' }, [
|
|
|
s.messageCount ? `${s.messageCount} 条` : '',
|
|
|
s.messageCount && meta ? ' · ' : '',
|
|
|
@@ -145,7 +134,7 @@
|
|
|
title: '重命名',
|
|
|
onClick: (e: Event) => {
|
|
|
e.stopPropagation();
|
|
|
- emit('start-rename');
|
|
|
+ emit('rename');
|
|
|
},
|
|
|
}),
|
|
|
h(Icon, {
|
|
|
@@ -192,13 +181,35 @@
|
|
|
}>();
|
|
|
|
|
|
const localKeyword = ref(props.keyword);
|
|
|
- const editingId = ref<string | null>(null);
|
|
|
+
|
|
|
+ /* ---------------- 重命名弹窗 ---------------- */
|
|
|
+ /** 会话标题列是 VARCHAR(128),前端按同长度硬限制 */
|
|
|
+ const TITLE_MAX_LENGTH = 128;
|
|
|
+
|
|
|
+ const renameVisible = ref(false);
|
|
|
+ const renameTargetId = ref<string | null>(null);
|
|
|
+ const renameDraft = ref('');
|
|
|
+ const renameInputRef = ref<{ focus?: () => void; select?: () => void } | null>(null);
|
|
|
+ const canRename = computed(() => renameDraft.value.trim().length > 0);
|
|
|
|
|
|
watch(
|
|
|
() => props.keyword,
|
|
|
(v) => (localKeyword.value = v),
|
|
|
);
|
|
|
|
|
|
+ /**
|
|
|
+ * 弹窗打开后自动聚焦并全选,方便直接覆盖输入。
|
|
|
+ *
|
|
|
+ * <p>ant-design-vue 4.2.6 的 Modal **没有** `afterOpenChange` 事件(只有 `afterClose`),
|
|
|
+ * 写 `@after-open-change` 不会触发,所以这里自己用 watch + nextTick 控。
|
|
|
+ */
|
|
|
+ watch(renameVisible, async (open) => {
|
|
|
+ if (!open) return;
|
|
|
+ await nextTick();
|
|
|
+ renameInputRef.value?.focus?.();
|
|
|
+ renameInputRef.value?.select?.();
|
|
|
+ });
|
|
|
+
|
|
|
function match(s: ChatSession) {
|
|
|
const kw = localKeyword.value.trim().toLowerCase();
|
|
|
if (!kw) return true;
|
|
|
@@ -208,17 +219,24 @@
|
|
|
const filteredPinned = computed(() => props.sessions.filter((s) => s.pinned).filter(match));
|
|
|
const filteredNormal = computed(() => props.sessions.filter((s) => !s.pinned).filter(match));
|
|
|
|
|
|
- function startRename(id: string, _title?: string) {
|
|
|
- editingId.value = id;
|
|
|
+ function startRename(id: string, title?: string) {
|
|
|
+ renameTargetId.value = id;
|
|
|
+ renameDraft.value = title || '';
|
|
|
+ renameVisible.value = true;
|
|
|
}
|
|
|
|
|
|
- function commitRename(id: string, title: string) {
|
|
|
- editingId.value = null;
|
|
|
- if (title) emit('rename', id, title);
|
|
|
+ function handleRenameOk() {
|
|
|
+ const title = renameDraft.value.trim();
|
|
|
+ const id = renameTargetId.value;
|
|
|
+ if (!title || !id) return;
|
|
|
+ renameVisible.value = false;
|
|
|
+ renameTargetId.value = null;
|
|
|
+ emit('rename', id, title);
|
|
|
}
|
|
|
|
|
|
- function cancelRename() {
|
|
|
- editingId.value = null;
|
|
|
+ function handleRenameCancel() {
|
|
|
+ renameVisible.value = false;
|
|
|
+ renameTargetId.value = null;
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
@@ -346,4 +364,17 @@
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ /*
|
|
|
+ * 重命名弹窗的字数提示。
|
|
|
+ * Modal 会被 teleport 到 body,scoped 样式命不中,所以放在全局块里。
|
|
|
+ * 注:ant-design-vue 4.2.6 的 show-count 只在 TextArea 上实现,单行 Input 不渲染计数,故自行实现。
|
|
|
+ */
|
|
|
+ .ai-rename__count {
|
|
|
+ margin-top: 6px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.4;
|
|
|
+ color: var(--ai-text-2);
|
|
|
+ text-align: right;
|
|
|
+ }
|
|
|
</style>
|