509 lines
13 KiB
Plaintext
509 lines
13 KiB
Plaintext
<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>
|