chore: initialize qiming workspace repository
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<view class="manual-wrapper">
|
||||
<scroll-view
|
||||
class="manual-container"
|
||||
direction="horizontal"
|
||||
:scroll-with-animation="true"
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<!-- 页面首页 -->
|
||||
<view
|
||||
v-if="pageHomeIndex && expandPageArea === ExpandPageAreaEnum.Yes"
|
||||
class="manual-box"
|
||||
@click="handleOpenPagePreview"
|
||||
>
|
||||
<text class="icon iconfont"></text>
|
||||
<text class="text">{{ t("Mobile.Common.pageHome") }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 工作台 -->
|
||||
<view v-if="isTaskAgent" class="manual-box" @click="handleOpenFileTree">
|
||||
<text class="icon iconfont"></text>
|
||||
<text class="text">{{ t("Mobile.Common.workspace") }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 沙盒选择 -->
|
||||
<view v-if="isTaskAgent && !readonly" class="manual-group">
|
||||
<!-- 自定义 Select 触发区 -->
|
||||
<view
|
||||
v-if="!isSandboxUnavailable && currentSandboxName"
|
||||
class="manual-box manual-select"
|
||||
@click="openSandboxDrawer"
|
||||
>
|
||||
<text class="text">{{ currentSandboxName }}</text>
|
||||
<text class="icon iconfont icon-arrow-down"></text>
|
||||
</view>
|
||||
|
||||
<!-- 不可用状态 -->
|
||||
<view v-else class="manual-box manual-select disabled">
|
||||
<text class="text">{{ sandboxDisabledText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<view
|
||||
v-if="allowOtherModel && currentModelName"
|
||||
class="manual-box manual-select"
|
||||
@click="modelSelectVisible = true"
|
||||
>
|
||||
<text class="text">{{ currentModelName }}</text>
|
||||
<text class="icon iconfont icon-arrow-down"></text>
|
||||
</view>
|
||||
|
||||
<template v-for="item in manualComponents" :key="item.id">
|
||||
<view
|
||||
class="manual-box"
|
||||
:class="{ active: isSelected(item) }"
|
||||
@click="handleSelectComponent(item)"
|
||||
>
|
||||
<text
|
||||
class="icon iconfont"
|
||||
:class="getIconClass(item.name)"
|
||||
v-if="getIconClass(item.name)"
|
||||
></text>
|
||||
<text class="text">{{ item.name }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<page-preview-iframe ref="pagePreviewIframeRef" />
|
||||
|
||||
<!-- 沙盒切换弹窗 -->
|
||||
<sandbox-select-modal
|
||||
:visible="sandboxVisible"
|
||||
:sandbox-list="sandboxList"
|
||||
:current-sandbox-id="currentSandboxId"
|
||||
:is-sandbox-switch-disabled="isSandboxSwitchDisabled"
|
||||
:readonly="readonly"
|
||||
@on-close="handleSandboxClose"
|
||||
@on-sandbox-change="handleSandboxChangeInside"
|
||||
/>
|
||||
|
||||
<!-- 模型选择弹窗 -->
|
||||
<model-select-modal
|
||||
v-if="allowOtherModel"
|
||||
:visible="modelSelectVisible"
|
||||
:agent-id="agentId"
|
||||
:agent-type="agentType"
|
||||
:current-model-id="currentModelId"
|
||||
:is-temp-chat="isTempChat"
|
||||
@on-close="modelSelectVisible = false"
|
||||
@on-model-change="handleModelChangeInside"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="uts" setup>
|
||||
import { ExpandPageAreaEnum } from "@/types/enums/agent.uts";
|
||||
import { AgentManualComponentInfo } from "@/types/interfaces/agent.uts";
|
||||
import { SandboxInfo } from "@/types/interfaces/sandbox";
|
||||
import SandboxSelectModal from "../sandbox-select-modal/sandbox-select-modal.uvue";
|
||||
import { useI18n } from "@/utils/i18n";
|
||||
import ModelSelectModal from "../model-select-modal/model-select-modal.uvue";
|
||||
import { AgentTypeEnum } from "@/types/enums/agent.uts";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
manualComponents: AgentManualComponentInfo[];
|
||||
selectedComponents: AgentSelectedComponentInfo[];
|
||||
isTaskAgent?: boolean;
|
||||
pageHomeIndex?: string;
|
||||
expandPageArea?: number;
|
||||
sandboxList?: SandboxInfo[];
|
||||
currentSandboxId?: string;
|
||||
isSandboxSwitchDisabled?: boolean;
|
||||
isSandboxUnavailable?: boolean;
|
||||
sandboxDisabledText?: string;
|
||||
readonly?: boolean;
|
||||
// 模型选择相关
|
||||
allowOtherModel?: boolean;
|
||||
currentModelId?: number;
|
||||
currentModelName?: string;
|
||||
agentId?: number;
|
||||
agentType?: AgentTypeEnum;
|
||||
isTempChat?: boolean;
|
||||
}>(),
|
||||
{
|
||||
manualComponents: () => [],
|
||||
selectedComponents: () => [],
|
||||
isTaskAgent: false,
|
||||
pageHomeIndex: "",
|
||||
expandPageArea: 0,
|
||||
sandboxList: () => [],
|
||||
currentSandboxId: "",
|
||||
isSandboxSwitchDisabled: false,
|
||||
isSandboxUnavailable: false,
|
||||
sandboxDisabledText: "",
|
||||
readonly: false,
|
||||
allowOtherModel: false,
|
||||
currentModelId: 0,
|
||||
currentModelName: "",
|
||||
agentId: 0,
|
||||
agentType: AgentTypeEnum.ChatBot,
|
||||
isTempChat: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
onSandboxChange: (sandboxId: string) => void;
|
||||
onModelChange: (modelId: number, name: string) => void;
|
||||
onSelectComponent: (item: AgentSelectedComponentInfo) => void;
|
||||
onOpenPagePreview: (uri: string) => void;
|
||||
onOpenFileTree: () => void;
|
||||
}>();
|
||||
|
||||
// 当前显示的沙盒名称
|
||||
const currentSandboxName = computed(() => {
|
||||
const item = props.sandboxList.find(
|
||||
(item) => item.sandboxId === props.currentSandboxId,
|
||||
);
|
||||
return item ? item.name : props.sandboxList[0]?.name || "";
|
||||
});
|
||||
|
||||
// 当前显示的模型名称使用 props 传入的值
|
||||
const modelSelectVisible = ref(false);
|
||||
|
||||
const handleModelChangeInside = (id: number, name: string) => {
|
||||
emit("onModelChange", id, name);
|
||||
modelSelectVisible.value = false;
|
||||
};
|
||||
// 页面预览iframe引用
|
||||
const pagePreviewIframeRef = ref<any>(null);
|
||||
const sandboxVisible = ref(false);
|
||||
|
||||
// 打开沙盒切换抽屉
|
||||
const openSandboxDrawer = () => {
|
||||
sandboxVisible.value = true;
|
||||
};
|
||||
|
||||
// 抽屉内切换沙盒反馈
|
||||
const handleSandboxChangeInside = (id: string) => {
|
||||
emit("onSandboxChange", id);
|
||||
sandboxVisible.value = false;
|
||||
};
|
||||
|
||||
const handleSandboxClose = () => {
|
||||
sandboxVisible.value = false;
|
||||
};
|
||||
|
||||
// 点击工作台,打开文件树弹窗
|
||||
const handleOpenFileTree = () => {
|
||||
emit("onOpenFileTree");
|
||||
};
|
||||
|
||||
// 打开页面预览
|
||||
const handleOpenPagePreview = () => {
|
||||
if (!props.pageHomeIndex) return;
|
||||
// #ifdef H5 || WEB
|
||||
let uri =
|
||||
process.env.NODE_ENV === "production"
|
||||
? `${window.location.origin}${props.pageHomeIndex}`
|
||||
: props.pageHomeIndex;
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
let uri = props.pageHomeIndex;
|
||||
// #endif
|
||||
|
||||
emit("onOpenPagePreview", uri);
|
||||
};
|
||||
|
||||
// 选择组件
|
||||
const handleSelectComponent = (item: AgentManualComponentInfo) => {
|
||||
const selectedComponent: AgentSelectedComponentInfo = {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
};
|
||||
emit("onSelectComponent", selectedComponent);
|
||||
};
|
||||
|
||||
// 判断组件是否被选中
|
||||
const isSelected = (item: AgentManualComponentInfo): boolean => {
|
||||
return props.selectedComponents.some((selected) => selected.id === item.id);
|
||||
};
|
||||
|
||||
// 根据组件名称返回对应的图标类名
|
||||
const getIconClass = (name: string): string => {
|
||||
const lowerName = (name || "").toLowerCase();
|
||||
if (
|
||||
name.includes("搜索") ||
|
||||
name.includes("互联网") ||
|
||||
lowerName.includes("search") ||
|
||||
lowerName.includes("internet")
|
||||
) {
|
||||
return "icon-Globe";
|
||||
} else if (
|
||||
name.includes("研究") ||
|
||||
name.includes("深度") ||
|
||||
lowerName.includes("research") ||
|
||||
lowerName.includes("deep")
|
||||
) {
|
||||
return "icon-bulb";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.manual-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 76rpx;
|
||||
|
||||
.manual-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 76rpx;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.manual-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.manual-box {
|
||||
gap: 6rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 76rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
margin-right: 16rpx;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #f0f0f0;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.active {
|
||||
background: #f2f2ff;
|
||||
border-color: #f2f2ff;
|
||||
|
||||
.icon {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #5147ff;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 32rpx;
|
||||
color: #6b7280;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: rgba(21, 23, 31, 0.7);
|
||||
font-size: 28rpx;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.manual-select {
|
||||
border-color: #eeeeee;
|
||||
min-width: 100rpx;
|
||||
max-width: 300rpx;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.text {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.icon-arrow-down {
|
||||
font-size: 24rpx;
|
||||
margin-left: -4rpx;
|
||||
color: rgba(21, 23, 31, 0.4);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
background: #f5f5f5;
|
||||
pointer-events: auto;
|
||||
|
||||
.text {
|
||||
color: rgba(21, 23, 31, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
background: #f5f5f5;
|
||||
pointer-events: none;
|
||||
|
||||
.text {
|
||||
color: rgba(21, 23, 31, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<radio-list-drawer
|
||||
:visible="visible"
|
||||
:title="t('Mobile.Chat.modelSelect')"
|
||||
:list="modelOptions"
|
||||
:current-value="currentModelId.toString()"
|
||||
:readonly="readonly"
|
||||
:loading="isLoading"
|
||||
@onClose="handleClose"
|
||||
@onChange="handleModelChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="uts" setup>
|
||||
import { AgentModelOption } from "@/types/interfaces/agent";
|
||||
import { AgentTypeEnum } from "@/types/enums/agent";
|
||||
import { RadioListItem } from "@/types/interfaces/radio";
|
||||
import RadioListDrawer from "@/components/radio-list-drawer/radio-list-drawer.uvue";
|
||||
import { apiGetAgentModelOptions } from "@/servers/agentDev";
|
||||
import { SUCCESS_CODE } from "@/constants/codes.constants";
|
||||
import { t } from "@/utils/i18n";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
agentId: number;
|
||||
agentType: AgentTypeEnum;
|
||||
currentModelId: number;
|
||||
readonly?: boolean;
|
||||
isTempChat?: boolean;
|
||||
}>(),
|
||||
{
|
||||
visible: false,
|
||||
readonly: false,
|
||||
isTempChat: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
onClose: () => void;
|
||||
onModelChange: (modelId: number) => void;
|
||||
}>();
|
||||
|
||||
const modelOptions = ref<RadioListItem[]>([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// 获取并过滤模型列表
|
||||
const fetchModelOptions = async () => {
|
||||
if (props.isTempChat) return;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
modelOptions.value = [];
|
||||
const res = await apiGetAgentModelOptions(props.agentId);
|
||||
if (res.code === SUCCESS_CODE && res.data) {
|
||||
|
||||
const filteredModels = res.data.filter((model: AgentModelOption) => {
|
||||
// 根据 usageScenarios 包含当前智能体类型来判断是否显示
|
||||
return model.usageScenarios?.includes(props.agentType);
|
||||
});
|
||||
|
||||
modelOptions.value = filteredModels.map((model: AgentModelOption) : RadioListItem => {
|
||||
return {
|
||||
label: model.name,
|
||||
value: model.id.toString(),
|
||||
desc: model.description || '',
|
||||
disabled: false,
|
||||
} as RadioListItem;
|
||||
});
|
||||
|
||||
// 如果当前没有选中模型,则默认选中第一个
|
||||
if (props.currentModelId === 0 && modelOptions.value.length > 0) {
|
||||
handleModelChange(modelOptions.value[0].value);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[model-select-modal] 获取模型选项失败:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit("onClose");
|
||||
};
|
||||
|
||||
const handleModelChange = (value: string) => {
|
||||
const item = modelOptions.value.find((i) => i.value === value);
|
||||
emit("onModelChange", parseInt(value), item?.label || "");
|
||||
};
|
||||
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
fetchModelOptions();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化加载数据,以便在外部显示首项名称(即使弹窗未打开)
|
||||
onMounted(() => {
|
||||
fetchModelOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<radio-list-drawer
|
||||
:visible="visible"
|
||||
:title="t('Mobile.Sandbox.selectorTitle')"
|
||||
:list="computedSandboxList"
|
||||
:current-value="currentSandboxId"
|
||||
:readonly="readonly"
|
||||
@onClose="handleClose"
|
||||
@onChange="handleSandboxChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="uts" setup>
|
||||
import { SandboxInfo } from "@/types/interfaces/sandbox";
|
||||
import { useI18n } from "@/utils/i18n";
|
||||
import { RadioListItem } from "@/types/interfaces/radio";
|
||||
import RadioListDrawer from "@/components/radio-list-drawer/radio-list-drawer.uvue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
sandboxList: SandboxInfo[];
|
||||
currentSandboxId?: string;
|
||||
isSandboxSwitchDisabled?: boolean;
|
||||
readonly?: boolean;
|
||||
}>(),
|
||||
{
|
||||
visible: false,
|
||||
sandboxList: () => [] as SandboxInfo[],
|
||||
currentSandboxId: "",
|
||||
isSandboxSwitchDisabled: false,
|
||||
readonly: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
onClose: () => void;
|
||||
onSandboxChange: (sandboxId: string) => void;
|
||||
}>();
|
||||
|
||||
const computedSandboxList = computed(() => {
|
||||
return props.sandboxList.map((item) : RadioListItem => {
|
||||
const isDisabled = props.isSandboxSwitchDisabled && item.sandboxId !== props.currentSandboxId;
|
||||
return {
|
||||
label: item.name,
|
||||
value: item.sandboxId,
|
||||
desc: item.description || '',
|
||||
disabled: isDisabled,
|
||||
warningDesc: item.sandboxId !== '-1'
|
||||
} as RadioListItem;
|
||||
});
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
emit("onClose");
|
||||
};
|
||||
|
||||
const handleSandboxChange = (value: string) => {
|
||||
emit("onSandboxChange", value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,508 @@
|
||||
<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>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<view class="skill-tags-wrapper" v-if="selectedSkills.length > 0">
|
||||
<scroll-view class="skill-tags-scroll" :scroll-x="true" direction="horizontal" :show-scrollbar="false">
|
||||
<view class="skill-tags-list">
|
||||
<view v-for="(skill, index) in selectedSkills" :key="skill.id" class="skill-tag">
|
||||
<text class="tag-text">{{ skill.name }}</text>
|
||||
<text class="tag-close iconfont icon-X" @click="handleRemove(index)"></text>
|
||||
</view>
|
||||
<view class="skill-tag-add" @click="handleAdd">
|
||||
<text class="iconfont icon-Plus"></text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts" setup>
|
||||
import { SkillInfoForAt } from '@/types/interfaces/skill';
|
||||
|
||||
defineProps<{
|
||||
selectedSkills: SkillInfoForAt[]
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'onRemove', index: number): void;
|
||||
(e: 'onAdd'): void;
|
||||
}>();
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
emit('onRemove', index);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
emit('onAdd');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skill-tags-wrapper {
|
||||
width: 100%;
|
||||
padding-bottom: 10rpx;
|
||||
border-bottom: 2rpx solid #f3f4f6;
|
||||
|
||||
.skill-tags-scroll {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
|
||||
.skill-tags-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
|
||||
.skill-tag {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 8rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
margin-right: 16rpx;
|
||||
|
||||
.tag-text {
|
||||
font-size: 24rpx;
|
||||
color: #4b5563;
|
||||
margin-right: 8rpx;
|
||||
max-width: 220rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-close {
|
||||
font-size: 24rpx;
|
||||
color: #9ca3af;
|
||||
padding: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.skill-tag-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 8rpx;
|
||||
width: 43rpx;
|
||||
height: 43rpx;
|
||||
|
||||
.icon-Plus {
|
||||
font-size: 24rpx;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user