feat: Conversations封装、首页开发

This commit is contained in:
rd
2025-08-19 18:01:30 +08:00
parent b717c7629f
commit c0fbf501e1
19 changed files with 594 additions and 29 deletions

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M6.10963 5.99245L5.88754 6.00922L2.72457 6.2548L7.74479 11.275L7.99037 8.11205L8.00714 7.88996L10.5716 5.3255L8.67409 3.42799L6.10963 5.99245ZM9.17397 8.42741L8.8472 12.6525C8.80683 13.1737 8.17358 13.4078 7.80384 13.0383L4.80861 10.0431L1.9355 12.9162C1.85964 12.9921 1.7363 12.992 1.6604 12.9162L1.08404 12.3399C1.00814 12.264 1.00814 12.1407 1.08404 12.0648L3.95715 9.19165L0.961248 6.19575C0.592209 5.82604 0.826163 5.19293 1.34706 5.15239L5.57218 4.82562L7.82196 2.57585L6.9356 1.68949C6.86001 1.61358 6.86048 1.49086 6.93627 1.41506L7.51264 0.838698C7.58845 0.763107 7.71122 0.7625 7.78706 0.838028L13.1616 6.21253C13.2374 6.28835 13.2373 6.41172 13.1616 6.48763L12.5852 7.06399C12.5093 7.13989 12.386 7.13989 12.3101 7.06399L11.4237 6.17764L9.17397 8.42741Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 901 B

View File

@ -1,6 +1,11 @@
<template> <template>
<a-tooltip :disabled="isShowBtn || (!isShowBtn && disabled)" :placement="props.placement"> <Tooltip
<template #content> :disabled="isShowBtn || (!isShowBtn && disabled)"
:placement="props.placement"
:mouseEnterDelay="props.mouseEnterDelay"
:mouseLeaveDelay="props.mouseLeaveDelay"
>
<template #title>
<div :style="contentStyle" class="tip-content">{{ props.context }}</div> <div :style="contentStyle" class="tip-content">{{ props.context }}</div>
</template> </template>
<div v-bind="$attrs" ref="Text" :class="`${isShow ? '' : `line-${props.line}`} `" class="overflow-text"> <div v-bind="$attrs" ref="Text" :class="`${isShow ? '' : `line-${props.line}`} `" class="overflow-text">
@ -18,11 +23,23 @@
{{ isShow ? '收起' : '展开' }} {{ isShow ? '收起' : '展开' }}
<icon-up size="16" :class="{ active: isShow }" class="ml-2px color-#8C8C8C" /> <icon-up size="16" :class="{ active: isShow }" class="ml-2px color-#8C8C8C" />
</div> </div>
</a-tooltip> </Tooltip>
</template> </template>
<script setup> <script setup>
import { ref, reactive, toRefs, onBeforeMount, onMounted, watchEffect, computed, watch, nextTick, defineProps } from 'vue'; import { Tooltip } from 'ant-design-vue';
import {
ref,
reactive,
toRefs,
onBeforeMount,
onMounted,
watchEffect,
computed,
watch,
nextTick,
defineProps,
} from 'vue';
import elementResizeDetectorMaker from 'element-resize-detector'; import elementResizeDetectorMaker from 'element-resize-detector';
const props = defineProps({ const props = defineProps({
@ -38,6 +55,14 @@ const props = defineProps({
type: Number, type: Number,
default: 1, default: 1,
}, },
mouseEnterDelay: {
type: Number,
default: 0.01,
},
mouseLeaveDelay: {
type: Number,
default: 0,
},
maxHeight: { maxHeight: {
type: [String, Number], type: [String, Number],
default: '', default: '',

View File

@ -0,0 +1,173 @@
<script lang="tsx">
import { ref, defineComponent, computed } from 'vue';
import { Input, Dropdown, Menu } from 'ant-design-vue';
import type { MenuProps } from 'ant-design-vue';
import type { VNode } from 'vue';
import SvgIcon from '@/components/svg-icon';
import TextoverTips from '@/components/text-over-tips';
// 定义对话项类型
interface ConversationItem {
key: string;
label: string | VNode;
icon?: string | VNode;
disabled?: boolean;
[key: string]: any;
}
const DEFAULT_MENU_CONFIG = [
{
label: '置顶',
key: 'pin',
icon: () => <SvgIcon name="svg-pushpin" size={14} class="color-#737478 hover:color-#6D4CFE" />,
},
{
label: '重命名',
key: 'rename',
icon: <icon-edit size={14} class="color-#737478" />,
},
{
label: '删除',
key: 'delete',
icon: <icon-delete size={14} class="color-#F64B31" />,
status: 'danger',
},
] as ConversationItem[];
export default defineComponent({
name: 'Conversations',
props: {
dataSource: {
type: Array as () => ConversationItem[],
default: () => [],
},
activeKey: {
type: String,
default: '',
},
defaultActiveKey: {
type: String,
default: '',
},
menu: {
type: Array as () => ConversationItem[],
default: () => DEFAULT_MENU_CONFIG,
},
},
emits: ['activeChange', 'menuClick', 'update:dataSource', 'update:modelValue', 'rename'],
setup(props, { emit, expose }) {
const activeKey = ref(props.activeKey || props.defaultActiveKey || '');
const localDataSource = ref<ConversationItem[]>([]);
const inputRef = ref(null);
const menuConfigs = ref<ConversationItem[]>(props.menu ?? DEFAULT_MENU_CONFIG);
// 处理选中变更
const handleActiveChange = (value: string) => {
activeKey.value = value;
emit('update:modelValue', value);
emit('activeChange', value);
};
const onMenuItemClick = ({ menuInfo, item }) => {
const { key } = menuInfo;
emit('menuClick', menuInfo);
switch (key) {
case 'rename':
item.editing = true;
nextTick(() => {
inputRef.value.focus();
});
break;
}
};
const changeItems = () => {
emit('update:dataSource', localDataSource.value);
};
watch(
() => props.dataSource,
(newItems) => {
if (newItems) {
localDataSource.value = cloneDeep(newItems);
}
},
{ deep: true, immediate: true },
);
const renderItems = () => {
return localDataSource.value.map((item, index) => (
<div
class={`group flex justify-between cursor-pointer items-center p-8px h-40px rounded-8px hover:bg-#F2F3F5 ${
activeKey.value === item.key ? 'bg-#F2F3F5' : ''
}`}
onClick={() => handleActiveChange(item.key)}
>
{item.editing ? (
<Input
ref={inputRef}
v-model:value={item.label}
onBlur={() => {
item.editing = false;
changeItems();
emit('rename', item);
}}
onPressEnter={() => {
item.editing = false;
}}
class="flex-1"
/>
) : (
<TextoverTips context={item.label} class="flex-1" placement="bottom" />
)}
<Dropdown
class="p-0"
overlayClassName="xt-conversations-dropdown"
placement="bottomRight"
v-slots={{
overlay: () => (
<Menu onClick={(menuInfo: MenuProps) => onMenuItemClick({ menuInfo, item })}>
{menuConfigs.value.map((menuItem) => (
<Menu.Item key={menuItem.key} icon={menuItem.icon} class={`${menuItem.status || ''}`}>
{menuItem.label}
</Menu.Item>
))}
</Menu>
),
}}
>
<icon-more size={16} class="color-#737478 cursor-pointer ml-8px opacity-0 group-hover:opacity-100" />
</Dropdown>
</div>
));
};
return () => <div className="xt-conversations-container">{renderItems()}</div>;
},
});
</script>
<style scoped lang="scss">
@import './style.scss';
</style>
<style lang="scss">
.xt-conversations-dropdown {
.ant-dropdown-menu {
padding: 4px 0;
.ant-dropdown-menu-item {
padding: 0 12px;
min-width: 124px;
height: 36px;
display: flex;
align-items: center;
&:hover {
background: var(--BG-200, #f2f3f5);
}
&.danger {
color: var(--RED-600, #f64b31);
}
}
}
}
</style>

View File

@ -0,0 +1,13 @@
.xt-conversations-container {
width: 100%;
height: 100%;
overflow-y: auto;
:deep(.overflow-text) {
color: var(--Text-1, #211f24);
font-family: $font-family-regular;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
}

View File

@ -27,13 +27,16 @@ const showSider = computed(() => {
return !route.meta?.hideSidebar; return !route.meta?.hideSidebar;
}); });
const isHomeRoute = computed(() => { const layoutPageClass = computed(() => {
return route.name === 'Home'; if (!showSider.value) {
return 'pb-8px pr-8px';
}
return 'pb-24px pr-24px';
}); });
const layoutPageClass = computed(() => { const siderWidth = computed(() => {
return isHomeRoute.value ? 'pb-8px pr-8px' : 'pb-24px pr-24px' return showSider.value ? sidebarStore.sidebarWidth : 0;
}) });
const collapsed = computed(() => { const collapsed = computed(() => {
return sidebarStore.menuCollapse; return sidebarStore.menuCollapse;
@ -53,7 +56,6 @@ const checkHasInviteCode = () => {
joinModalRef.value?.getEnterprise?.(); joinModalRef.value?.getEnterprise?.();
} }
}; };
</script> </script>
<template> <template>
@ -66,7 +68,7 @@ const checkHasInviteCode = () => {
<Layout.Sider <Layout.Sider
v-if="showSider" v-if="showSider"
v-model="collapsed" v-model="collapsed"
:width="sidebarStore.sidebarWidth" :width="siderWidth"
collapsible collapsible
trigger trigger
@collapse="setCollapsed" @collapse="setCollapsed"
@ -74,16 +76,16 @@ const checkHasInviteCode = () => {
> >
<SiderBar /> <SiderBar />
</Layout.Sider> </Layout.Sider>
<Layout.Content <Layout
class="layout-content" class="layout-content"
:style="{ :style="{
paddingLeft: sidebarStore.sidebarWidth + 'px', paddingLeft: `${siderWidth}px`,
}" }"
> >
<div :class="layoutPageClass"> <Layout.Content :class="layoutPageClass">
<layout-page /> <layout-page />
</div> </Layout.Content>
</Layout.Content> </Layout>
</Layout> </Layout>
</Layout> </Layout>
</template> </template>
@ -102,7 +104,7 @@ const checkHasInviteCode = () => {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
z-index: 9999; z-index: 999;
width: 100%; width: 100%;
} }
.layout-content-wrap { .layout-content-wrap {
@ -118,7 +120,7 @@ const checkHasInviteCode = () => {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
z-index: 9999; z-index: 999;
height: 100%; height: 100%;
transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1); transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
.ant-layout-sider-trigger { .ant-layout-sider-trigger {

View File

@ -1,12 +1,14 @@
<script lang="jsx"> <script lang="jsx">
import { Sender } from 'ant-design-x-vue';
import { Input } from 'ant-design-vue'; import { Input } from 'ant-design-vue';
import { handleUserHome } from '@/utils/user.ts';
export default { export default {
setup(props, { emit, expose }) { setup(props, { emit, expose }) {
const keyWord = ref(''); const keyWord = ref('');
const handleSearch = () => { const handleSearch = () => {
console.log('handleSearch', keyWord.value); handleUserHome({ keyWord: keyWord.value });
keyWord.value = '';
}; };
return () => ( return () => (
<div class="middle-wrap h-100% flex-1 flex items-center justify-center px-24px"> <div class="middle-wrap h-100% flex-1 flex items-center justify-center px-24px">

View File

@ -4,7 +4,7 @@
<img src="@/assets/img/icon-logo.png" alt="" width="96" height="24" /> <img src="@/assets/img/icon-logo.png" alt="" width="96" height="24" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<MiddleSide /> <MiddleSide v-if="!isHomeRoute" />
</div> </div>
<RightSide :isAgentRoute="isAgentRoute" v-if="userStore.isLogin" /> <RightSide :isAgentRoute="isAgentRoute" v-if="userStore.isLogin" />
</div> </div>
@ -24,6 +24,10 @@ const userStore = useUserStore();
const isAgentRoute = computed(() => { const isAgentRoute = computed(() => {
return route.meta?.isAgentRoute; return route.meta?.isAgentRoute;
}); });
const isHomeRoute = computed(() => {
return route.name === 'Home';
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.navbar-wrap { .navbar-wrap {

View File

@ -0,0 +1,20 @@
import { defineStore } from 'pinia';
interface SharedDataState {
routeParams: Record<string, any> | null;
}
export const useSharedDataStore = defineStore('sharedData', {
state: (): SharedDataState => ({
routeParams: null,
}),
actions: {
setRouteParams(params: Record<string, any>) {
this.routeParams = params;
},
clearRouteParams() {
this.routeParams = null;
},
},
});

View File

@ -1,7 +1,3 @@
/*
* @Author: RenXiaoDong
* @Date: 2025-06-19 01:45:53
*/
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import type { TabBarState, TagProps, RouteLocationNormalized } from './types'; import type { TabBarState, TagProps, RouteLocationNormalized } from './types';

View File

@ -0,0 +1,37 @@
.ant-sender {
border-radius: 8px;
border: 1px solid var(--Border-2, #e6e6e8);
background: var(--BG-White, #fff);
box-shadow: none;
transition: all 0.3s;
&::after {
display: none;
}
.ant-sender-content {
height: 100%;
padding: 14px 8px 8px 16px;
.ant-input {
font-family: $font-family-regular;
color: #211f24;
height: 100% !important;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
&::placeholder {
color: #939499;
}
}
}
&:hover {
border: 1px solid var(--Brand-6, #6d4cfe);
box-shadow: 0 8px 16px 0 rgba(109, 76, 254, 0.15);
}
&:focus-within {
border-color: #6d4cfe;
box-shadow: 0 8px 16px 0 rgba(109, 76, 254, 0.15);
.ant-input {
caret-color: #6d4cfe;
}
}
}

View File

@ -10,3 +10,4 @@
@import "./button.scss"; @import "./button.scss";
@import "./steps.scss"; @import "./steps.scss";
@import "./form.scss"; @import "./form.scss";
@import "./chat-sender.scss";

View File

@ -7,6 +7,7 @@ import router from '@/router';
import { useUserStore } from '@/stores'; import { useUserStore } from '@/stores';
import { useEnterpriseStore } from '@/stores/modules/enterprise'; import { useEnterpriseStore } from '@/stores/modules/enterprise';
import { useSidebarStore } from '@/stores/modules/side-bar'; import { useSidebarStore } from '@/stores/modules/side-bar';
import { useSharedDataStore } from '@/stores/modules/share-data';
// 登录 // 登录
export function goUserLogin(query?: any) { export function goUserLogin(query?: any) {
@ -36,8 +37,12 @@ export async function handleUserLogin() {
} }
// 首页 // 首页
export function handleUserHome() { export function handleUserHome(params?: any) {
router.push({ name: 'Home' }); if (params) {
const sharedDataStore = useSharedDataStore();
sharedDataStore.setRouteParams(params);
}
router.push({ name: 'Home', params });
} }
// 登出处理 // 登出处理

View File

@ -0,0 +1,51 @@
<template>
<a-modal v-model:visible="visible" title="删除对话" width="400px" @close="onClose">
<div class="flex items-center">
<img :src="icon1" width="20" height="20" class="mr-12px" />
<span>确认删除对话吗删除后聊天记录将不可恢复</span>
</div>
<template #footer>
<a-button size="large" @click="onClose">取消</a-button>
<a-button type="primary" class="ml-16px !bg-#f64b31 !border-none" status="danger" size="large" @click="onDelete"
>确定</a-button
>
</template>
</a-modal>
</template>
<script setup>
import { ref } from 'vue';
import { deleteTask, deleteBatchTasks } from '@/api/all/common';
import icon1 from '@/assets/img/media-account/icon-warn-1.png';
const emits = defineEmits(['update', 'close', 'batchUpdate']);
const visible = ref(false);
const chatId = ref(null);
const isBatch = computed(() => Array.isArray(taskId.value));
function onClose() {
visible.value = false;
chatId.value = null;
emits('close');
}
const open = (record) => {
const { id = null } = record;
chatId.value = id;
visible.value = true;
};
async function onDelete() {
const { code } = await deleteTask(chatId.value);
if (code === 200) {
AMessage.success('删除成功');
emits('delete', chatId.value);
onClose();
}
}
defineExpose({ open });
</script>

View File

@ -0,0 +1,74 @@
<script lang="jsx">
import { Drawer } from 'ant-design-vue';
import TextoverTips from '@/components/text-over-tips';
import Conversations from '@/components/xt-chat/conversations';
import SvgIcon from '@/components/svg-icon';
import { Button, Flex, Input } from 'ant-design-vue';
import DeleteChatModal from './delete-chat-modal.vue';
export default {
setup(props, { emit, expose }) {
const open = ref(false);
const dataSource = ref([]);
const activeKey = ref('');
const deleteChatModalRef = ref(null);
const showDrawer = () => {
getData();
open.value = true;
};
const getData = () => {
dataSource.value = Array.from({ length: 4 }).map((conversation, index) => ({
key: `item${index + 1}`,
label: `Conversation Item ${index + 1}Conversation Item 1`,
}));
};
const onClose = () => {
open.value = false;
};
const handleMenuClick = (menuInfo) => {
const { item, key } = menuInfo;
switch (key) {
case 'pin':
console.log('置顶');
break;
case 'rename':
item.editing = true;
break;
case 'delete':
deleteChatModalRef.value.open(item);
break;
}
};
const handleRename = (item) => {
console.log('handleRename', item);
};
expose({
showDrawer,
});
return () => (
<Drawer width={240} rootClassName="ct-history-conversation-drawer" v-model:open={open.value} onClose={onClose}>
<header class="header h-40px px-12px flex justify-between items-center">
<span class="s1">历史对话</span>
<icon-close size={16} class="color-#211F24 cursor-pointer" onClick={onClose} />
</header>
<section class="flex-1 overflow-y-auto content p-12px">
<Conversations
v-model={activeKey.value}
dataSource={dataSource.value}
onMenuClick={handleMenuClick}
onRename={handleRename}
/>
</section>
<DeleteChatModal ref={deleteChatModalRef} />
</Drawer>
);
},
};
</script>
<style lang="scss">
@import './style.scss';
</style>

View File

@ -0,0 +1,37 @@
.ct-history-conversation-drawer {
border-radius: 8px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.1);
.ant-drawer-content {
.ant-drawer-header {
display: none;
}
.ant-drawer-body {
padding: 0;
display: flex;
flex-direction: column;
.header {
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 1px;
margin: 0 16px;
background: #f2f3f5;
}
}
.content {
.ant-conversations {
gap: 0;
padding: 0;
.ant-conversations-item {
gap: 0;
padding: 8px;
}
}
}
}
}
}

View File

@ -1,7 +1,75 @@
<script lang="jsx"> <script lang="jsx">
import HistoryConversationDrawer from './components/history-conversation-drawer';
import { Sender } from 'ant-design-x-vue';
import { message } from 'ant-design-vue';
import { useSharedDataStore } from '@/stores/modules/share-data';
export default { export default {
setup(props, { emit, expose }) { setup(props, { emit, expose }) {
return () => <div>home</div>; const historyConversationDrawerRef = ref(null);
const searchValue = ref('');
const sharedDataStore = useSharedDataStore();
const handleSubmit = () => {
handleSearch();
searchValue.value = '';
};
const handleSearch = () => {
message.info('handleSearch');
};
onMounted(() => {
const params = sharedDataStore.routeParams;
if (params) {
searchValue.value = params.keyWord;
sharedDataStore.clearRouteParams();
handleSubmit();
}
});
return () => (
<div class="home-wrap rounded-12px w-full h-full">
<div class="w-full h-full flex justify-center ">
<div class="main-chat-wrap w-600px pt-120px">
<p class="title mb-16px">
<span>营小智7x24小时</span>
<span class="s1">AI营销</span>
<span>团队</span>
</p>
<p class="cts text-center mb-104px">AI 辅助账号托管账号 自动生成爆款内容 定时任务发布</p>
<Sender
v-model:value={searchValue.value}
onSubmit={handleSubmit}
class="h-120px w-full mb-24px"
placeholder="随时告诉我你想做什么,比如查数据、发任务、写内容,我会立刻帮你完成。"
actions={() => (
<div
onClick={handleSubmit}
class={`submit-btn w-32px h-32px p-6px flex justify-center items-center rounded-50% cursor-pointer ${
!searchValue.value ? 'opacity-50' : ''
}`}
>
<icon-arrow-right size={20} class="color-#FFFFFF" />
</div>
)}
/>
<p class="cts">可以试试这样下发任务</p>
</div>
</div>
{/**历史对话入口 */}
<div
class="history-conversation-btn cursor-pointer bg-#fff flex flex-col justify-center w-28px px-10px py-8px"
onClick={() => historyConversationDrawerRef.value.showDrawer()}
>
<span class="s1">历史对话</span>
</div>
<HistoryConversationDrawer ref={historyConversationDrawerRef} />
</div>
);
}, },
}; };
</script> </script>

View File

@ -0,0 +1,54 @@
.home-wrap {
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.9);
.cts {
color: var(--Text-1, #737478);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
.main-chat-wrap {
.title {
color: var(--Text-1, #211f24);
text-align: center;
font-family: $font-family-medium;
font-size: 32px;
font-style: normal;
font-weight: 700;
line-height: 48px;
.s1 {
background: linear-gradient(90deg, #6d4cfe 38.12%, #b93bf0 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
:deep(.ant-sender) {
.submit-btn {
background: linear-gradient(125deg, #6d4cfe 32.25%, #3ba1f0 72.31%),
linear-gradient(113deg, #6d4cfe 0%, #b93bf0 100%);
}
}
}
.history-conversation-btn {
border-radius: 8px 0 0 8px;
border-top: 1px solid #eabaff;
border-bottom: 1px solid #eabaff;
border-left: 1px solid #eabaff;
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
.s1 {
color: var(--Text-1, #211f24);
font-family: $font-family-regular;
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 180% */
}
}
}