Files
qiming/qiming-mobile/subpackages/components/chat-input-phone/skill-select-modal/skill-select-modal.uvue

509 lines
13 KiB
Plaintext
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>
<drawer-popup
ref="drawerPopupRef"
direction="bottom"
height="70vh"
:show-header="false"
@update-visible="handleUpdateVisible"
>
<view class="skill-select-container">
<!-- 头部 Tab 切换(带过渡动画的指示器) -->
<view class="skill-tabs">
<view
v-for="(tab, index) in tabs"
:key="index"
class="tab-item"
:class="{ active: currentTabIndex === index }"
@click="switchTabByIndex(index)"
>
<text class="tab-text">{{ tab.name }}</text>
</view>
<!-- 滑动指示器(带过渡动画) -->
<view
class="tab-indicator"
:style="{
width: indicatorWidth + 'px',
left: indicatorLeft + 'px',
}"
/>
</view>
<!-- 搜索框 -->
<view class="search-bar">
<input
class="search-input"
v-model="searchKw"
:placeholder="t('Mobile.Skill.searchSkillName')"
@input="handleInput"
:confirm-type="'search'"
@confirm="() => fetchSkillsForTab(currentTab)"
/>
<text
v-if="searchKw"
class="iconfont icon-cross clear-icon"
@click="clearSearch"
/>
</view>
<!-- 技能列表swiper 实现滑动过渡动画) -->
<swiper
class="skill-swiper"
:current="currentTabIndex"
@change="handleSwiperChange"
:duration="300"
easing-function="easeOut"
>
<swiper-item
v-for="(tab, index) in tabs"
:key="tab.value"
class="swiper-item"
>
<scroll-view
class="skill-list"
scroll-y="true"
:refresher-enabled="tab.value === 'all'"
:refresher-triggered="refresherTriggered"
@refresherrefresh="onRefresh"
@scrolltolower="onLoadMore"
>
<view
v-if="loading && (listDataByTab[tab.value] || []).length === 0"
class="loading-state"
>
<text class="loading-text">{{
t("Mobile.Page.loadingMore")
}}</text>
</view>
<view
v-else-if="
!loading && (listDataByTab[tab.value] || []).length === 0
"
class="empty-state"
>
<text class="empty-text">{{ t("Mobile.Skill.empty") }}</text>
</view>
<template v-else>
<view
v-for="item in listDataByTab[tab.value] || []"
:key="item.id"
class="skill-item"
@click="selectSkill(item)"
>
<image
class="skill-icon"
:src="item.icon || '/static/logo.png'"
mode="aspectFill"
:alt="item.name || t('Mobile.Common.skillIconAlt')"
/>
<view class="skill-info">
<text class="skill-name">{{ item.name }}</text>
<text class="skill-desc">{{ item.description }}</text>
</view>
</view>
</template>
<uni-load-more
v-if="
tab.value === 'all' &&
(listDataByTab[tab.value] || []).length > 0
"
:status="loadMoreStatus"
:content-text="loadMoreText"
/>
</scroll-view>
</swiper-item>
</swiper>
</view>
</drawer-popup>
</template>
<script lang="uts" setup>
import {
apiSkillListForAt,
apiSkillRecentlyUsedList,
apiSkillCollectList,
} from "@/subpackages/servers/skill";
import {
SkillInfoForAt,
SkillListForAtParams,
} from "@/types/interfaces/skill";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import { useI18n } from "@/utils/i18n";
import { getUniLoadMoreContentText } from "@/utils/i18n-third-party";
// 类型定义
import { AgentTypeEnum } from "@/types/enums/agent";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
visible: boolean;
}>(),
{
visible: false,
},
);
const emit = defineEmits<{
onClose: () => void;
onSelect: (skill: SkillInfoForAt) => void;
}>();
const drawerPopupRef = ref<any>(null);
const handleUpdateVisible = (val: boolean) => {
if (!val) {
handleClose();
}
};
const tabs = computed(() => [
{ name: t("Mobile.Common.all"), value: "all" },
{ name: t("Mobile.Skill.recentUsed"), value: "recent" },
{ name: t("Mobile.Skill.myFavorites"), value: "collect" },
]);
// 当前 Tab 索引(用于 swiper 和指示器)
const currentTabIndex = ref<number>(0);
const searchKw = ref<string>("");
// 按 Tab 分别存储列表数据,支持滑动过渡时展示各自内容
const listDataByTab = ref<Record<string, SkillInfoForAt[]>>({
all: [],
recent: [],
collect: [],
});
const loading = ref<boolean>(false);
// 分页及刷新状态仅「全部」Tab 使用)
const page = ref<number>(1);
const hasMore = ref<boolean>(true);
const refresherTriggered = ref<boolean>(false);
const loadMoreStatus = ref<string>("more");
const loadMoreText = computed(() => getUniLoadMoreContentText());
// Tab 指示器(带过渡动画)
const indicatorWidth = ref<number>(0);
const indicatorLeft = ref<number>(0);
// 防抖定时器
let searchTimer: any = null;
const currentTab = computed(
() => tabs.value[currentTabIndex.value]?.value ?? "all",
);
const handleInput = () => {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
fetchSkillsForTab(currentTab.value);
}, 300);
};
const clearSearch = () => {
searchKw.value = "";
fetchSkillsForTab(currentTab.value);
};
/** 更新 Tab 指示器位置(带过渡动画) */
const updateIndicator = () => {
nextTick(() => {
const systemInfo = uni.getSystemInfoSync();
const screenWidth = systemInfo.windowWidth || 375;
// 与 skill-tabs 的 padding: 0 40rpx 对应(设计稿 750rpx
const leftPadding = (screenWidth / 750) * 40;
const totalPadding = leftPadding * 2;
const tabsWidth = screenWidth - totalPadding;
const tabWidth = tabsWidth / tabs.value.length;
indicatorWidth.value = tabWidth;
indicatorLeft.value = leftPadding + currentTabIndex.value * tabWidth;
});
};
/** 点击 Tab 切换 */
const switchTabByIndex = (index: number) => {
if (currentTabIndex.value === index) return;
currentTabIndex.value = index;
updateIndicator();
fetchSkillsForTab(tabs.value[index].value);
};
/** Swiper 滑动切换 */
const handleSwiperChange = (e: any) => {
const index = e.detail?.current ?? 0;
if (currentTabIndex.value === index) return;
currentTabIndex.value = index;
updateIndicator();
fetchSkillsForTab(tabs.value[index].value);
};
const handleClose = () => {
searchKw.value = "";
// 关闭时清空数据,下次打开时重新拉取
listDataByTab.value = { all: [], recent: [], collect: [] };
emit("onClose");
};
const selectSkill = (skill: SkillInfoForAt) => {
emit("onSelect", skill);
handleClose();
};
/** 获取指定 Tab 的技能列表 */
const fetchSkillsForTab = async (
tab: string,
isLoadMore: boolean = false,
) => {
if (isLoadMore && loading.value) return;
if (!isLoadMore) {
page.value = 1;
hasMore.value = true;
loadMoreStatus.value = "more";
listDataByTab.value = { ...listDataByTab.value, [tab]: [] };
} else {
page.value++;
loadMoreStatus.value = "loading";
}
loading.value = true;
const fetchTab = tab;
try {
const kw = searchKw.value.trim();
if (tab === "all") {
const params: SkillListForAtParams = {
targetType: "Skill",
page: page.value,
pageSize: 20,
usageScenarios: [AgentTypeEnum.TaskAgent],
};
if (kw) {
params.kw = kw;
}
const res = await apiSkillListForAt(params);
if (currentTab.value !== fetchTab) return;
if (res.code === SUCCESS_CODE && res.data?.records) {
const records = res.data.records;
const prev = listDataByTab.value[tab] || [];
const newList = isLoadMore ? [...prev, ...records] : records;
listDataByTab.value = { ...listDataByTab.value, [tab]: newList };
hasMore.value = newList.length < res.data.total;
loadMoreStatus.value = hasMore.value ? "more" : "no-more";
} else if (!isLoadMore) {
listDataByTab.value = { ...listDataByTab.value, [tab]: [] };
loadMoreStatus.value = "no-more";
}
} else {
const params = {
targetType: "Skill",
kw: kw,
usageScenarios: [AgentTypeEnum.TaskAgent],
};
const res =
tab === "recent"
? await apiSkillRecentlyUsedList(params)
: await apiSkillCollectList(params);
if (currentTab.value !== fetchTab) return;
if (res.code === SUCCESS_CODE && res.data) {
listDataByTab.value = { ...listDataByTab.value, [tab]: res.data };
} else {
listDataByTab.value = { ...listDataByTab.value, [tab]: [] };
}
}
} catch (e) {
console.error("Fetch skills failed:", e);
if (!isLoadMore) {
listDataByTab.value = { ...listDataByTab.value, [tab]: [] };
}
} finally {
if (currentTab.value === fetchTab) {
loading.value = false;
refresherTriggered.value = false;
}
}
};
// 下拉刷新
const onRefresh = () => {
refresherTriggered.value = true;
fetchSkillsForTab("all", false);
};
// 上滑加载更多
const onLoadMore = () => {
if (currentTab.value === "all" && hasMore.value && !loading.value) {
fetchSkillsForTab("all", true);
}
};
// 监听显示状态
watch(
() => props.visible,
(newVal) => {
if (newVal) {
currentTabIndex.value = 0;
searchKw.value = "";
updateIndicator();
fetchSkillsForTab("all");
drawerPopupRef.value?.open();
} else {
drawerPopupRef.value?.close();
}
},
);
// 初始化指示器
onMounted(() => {
setTimeout(() => updateIndicator(), 100);
});
</script>
<style lang="scss" scoped>
.skill-select-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 10rpx 0;
width: 100%;
/* Tabs样式带过渡动画的指示器 */
.skill-tabs {
position: relative;
display: flex;
flex-direction: row;
border-bottom: 2rpx solid #f0f0f0;
padding: 0 40rpx;
.tab-item {
flex: 1;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding-bottom: 20rpx;
&.active {
.tab-text {
color: #4f46e5;
font-weight: 500;
}
}
.tab-text {
font-size: 28rpx;
color: #6b7280;
transition: color 0.2s ease;
}
}
.tab-indicator {
position: absolute;
bottom: 0;
height: 4rpx;
background-color: #4f46e5;
border-radius: 2rpx;
transition:
left 0.3s ease,
width 0.3s ease;
}
}
/* Swiper 滑动区域 */
.skill-swiper {
flex: 1;
overflow: hidden;
.swiper-item {
height: 100%;
}
}
/* 搜索框 */
.search-bar {
margin: 20rpx 40rpx;
background-color: #f3f4f6;
border-radius: 16rpx;
padding: 16rpx 24rpx;
display: flex;
flex-direction: row;
align-items: center;
position: relative;
.search-input {
flex: 1;
font-size: 28rpx;
background-color: transparent;
}
.clear-icon {
font-size: 24rpx;
color: #9ca3af;
padding: 10rpx;
}
}
/* 列表视图 */
.skill-list {
flex: 1;
padding: 0 40rpx;
overflow: hidden;
.skill-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 12rpx 0;
border-bottom: 2rpx solid #f9fafb;
&:active {
background-color: #f9fafb;
}
.skill-icon {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
margin-right: 24rpx;
background-color: #f3f4f6;
}
.skill-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
.skill-name {
font-size: 28rpx;
color: #111827;
font-weight: 500;
}
.skill-desc {
font-size: 24rpx;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
/* 状态视图 */
.loading-state,
.empty-state {
padding-top: 100rpx;
align-items: center;
text-align: center;
.loading-text,
.empty-text {
font-size: 28rpx;
color: #9ca3af;
}
}
}
</style>