chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,269 @@
<template>
<view class="agent-conversation-history h-full">
<scroll-view
class="h-full"
scroll-y
@scrolltolower="handleLoadMore"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
:show-scrollbar="false"
>
<!-- 会话列表 -->
<template v-if="conversationList?.length">
<view class="list-wrapper">
<view
class="conversation-item"
hover-class="hover-class"
hover-start-time="50"
v-for="item in conversationList"
:key="item.id"
@click="onConversationClick(item)"
>
<text class="conversation-title text-ellipsis">{{
item.topic || t("Mobile.Conversation.unnamed")
}}</text>
<text class="conversation-date">{{
formatDate(item.modified)
}}</text>
</view>
</view>
</template>
<!-- loading状态或刷新状态时隐藏空状态 -->
<template v-else-if="!loading && !refreshing">
<view class="empty-state h-full">
<empty-state />
</view>
</template>
<!-- 刷新状态时隐藏加载状态 -->
<template v-else-if="!refreshing">
<view class="loading-state">
<text class="loading-text">{{ t("Mobile.Page.loadingMore") }}</text>
</view>
</template>
<!-- 没有更多数据提示 -->
<template v-if="showNoMore">
<view class="no-more-state">
<text class="no-more-text">{{ t("Mobile.Common.noMoreData") }}</text>
</view>
</template>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import type {
ConversationInfo,
ConversationListParams,
} from "@/types/interfaces/conversationInfo";
import { formatDate } from "@/utils/system";
import { jumpToAgentDetailPage } from "@/utils/commonBusiness";
import { apiAgentConversationList } from "@/servers/conversation";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
interface Props {
agentId: number;
}
// 接收组件属性
const props = withDefaults(defineProps<Props>(), {});
// 会话列表
const conversationList = ref<ConversationInfo[]>([]);
// 加载状态
const loading = ref<boolean>(false);
// 刷新状态
const refreshing = ref<boolean>(false);
// 是否还有更多
const hasMore = ref<boolean>(true);
// 最后一条记录ID
const lastId = ref<number | null>(null);
// 是否显示没有更多数据
const showNoMore = computed(() => {
return (
!loading.value && !hasMore.value && conversationList.value?.length > 0
);
});
// 获取会话列表
const fetchConversationList = async (isLoadMore: boolean = false) => {
try {
const params: ConversationListParams = {
agentId: props.agentId,
lastId: isLoadMore ? lastId.value : null,
limit: 20,
} as ConversationListParams;
const res = await apiAgentConversationList(params);
loading.value = false;
const { code, data } = res || {};
if (code === SUCCESS_CODE) {
const newList = (data || []) as ConversationInfo[];
if (isLoadMore) {
// 追加数据
conversationList.value = [...conversationList.value, ...newList];
} else {
// 替换数据
conversationList.value = newList;
}
// 更新lastId和hasMore
if (data && data.length > 0) {
lastId.value = data[data.length - 1].id;
hasMore.value = newList.length >= 20;
} else {
hasMore.value = false;
}
return data;
}
} catch (error) {
loading.value = false;
return false;
}
};
// 下拉刷新
const handleRefresh = async () => {
if (refreshing.value) {
return;
}
refreshing.value = true;
lastId.value = null;
hasMore.value = true;
await fetchConversationList(false);
setTimeout(() => {
refreshing.value = false;
}, 500);
};
// 加载更多
const handleLoadMore = () => {
if (hasMore.value && !loading.value) {
loading.value = true;
fetchConversationList(true);
}
};
// 会话项点击处理
const onConversationClick = (item: ConversationInfo) => {
// 跳转到智能体详情页面,并传递消息数据
jumpToAgentDetailPage(item.agentId, item.id);
};
// 组件挂载时加载数据
onMounted(() => {
loading.value = true;
fetchConversationList(false);
});
</script>
<style lang="scss" scoped>
.agent-conversation-history {
.list-wrapper {
padding: 16rpx;
.conversation-item {
height: 76rpx;
padding: 0 16rpx;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s;
gap: 32rpx;
border-radius: 16rpx;
&.hover-class {
background-color: rgba(12, 20, 102, 0.04);
}
.conversation-title {
color: #000;
font-size: 28rpx;
font-weight: 400;
flex: 1;
}
.conversation-date {
text-align: right;
font-size: 24rpx;
color: rgba(0, 0, 0, 0.45);
}
}
}
}
.h-full {
height: 100%;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* 加载状态 */
.loading-state {
width: 100%;
padding: 40rpx 0;
display: flex;
align-items: center;
justify-content: center;
.loading-text {
font-size: 28rpx;
color: #666;
display: flex;
align-items: center;
flex-direction: row;
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
&::before {
content: "";
width: 32rpx;
height: 32rpx;
border: 4rpx solid #e8e8e8;
border-top: 4rpx solid #8b5cf6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 16rpx;
}
}
}
/* 没有更多数据状态 */
.no-more-state {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
.no-more-text {
font-size: 24rpx;
color: #999;
}
}
</style>

View File

@@ -0,0 +1,560 @@
<template>
<modal-popup
ref="modalRef"
:title="t('Mobile.Chat.subscriptionModalTitle')"
width="90vw"
@update-visible="handleUpdateVisible"
>
<view class="agent-sub-container">
<!-- 骨架屏 / 加载状态 -->
<view v-if="loading" class="loading-state">
<text class="loading-icon">⏳</text>
<text class="loading-text">{{ t("Mobile.Common.loading") }}</text>
</view>
<!-- 空状态 -->
<view v-else-if="plans.length === 0" class="empty-state">
<text class="empty-icon">📁</text>
<text class="empty-text">{{ t("Mobile.MySubscriptions.empty") }}</text>
</view>
<!-- 套餐卡片列表 -->
<scroll-view
v-else
class="plans-scroll"
scroll-y="true"
:show-scrollbar="false"
>
<view class="plans-list">
<view
v-for="plan in plans"
:key="plan.id"
class="plan-card"
:class="{
'plan-current': plan.id === activePlanId,
'plan-expired':
plan.id === activePlanId &&
currentSubscription?.status === 'EXPIRED',
'plan-cancelled':
plan.id === activePlanId &&
currentSubscription?.status === 'CANCELLED',
}"
>
<!-- 热门标签 / 订阅状态标签 -->
<view
class="card-badge-row"
v-if="plan.id === activePlanId || plan.isHot"
>
<view
v-if="plan.id === activePlanId"
class="badge"
:class="{
'badge-active':
currentSubscription == null ||
currentSubscription.status === 'ACTIVE',
'badge-expired':
currentSubscription != null &&
currentSubscription.status === 'EXPIRED',
'badge-cancelled':
currentSubscription != null &&
currentSubscription.status === 'CANCELLED',
}"
>
<text class="badge-text">{{
currentSubscription != null &&
plan.id === currentSubscription.planId
? getSubscriptionStatusText(currentSubscription.status)
: t("Mobile.MySubscriptions.currentPlan")
}}</text>
</view>
<view v-else-if="plan.isHot" class="badge badge-hot">
<text class="badge-text">{{
t("Mobile.MySubscriptions.statusHot")
}}</text>
</view>
</view>
<!-- 计划核心信息 -->
<view class="card-main">
<view class="plan-info-header">
<text class="plan-name">{{ plan.name }}</text>
<text class="plan-desc" v-if="plan.description">{{
plan.description
}}</text>
</view>
<!-- 价格与计费周期 -->
<view class="plan-price-box">
<text class="price-symbol">¥</text>
<text class="price-value">{{
formatNumber(plan.price, 2)
}}</text>
<text class="price-period">{{
getPeriodUnit(plan.period)
}}</text>
</view>
<!-- 权益清单 -->
<view class="plan-limits">
<view class="limit-item" v-if="plan.callLimitCount != null">
<text class="limit-bullet">✦</text>
<text class="limit-label"
>{{ t("Mobile.Chat.callLimitLabel") }}</text
>
<text class="limit-val">{{
plan.callLimitCount === -1
? t("Mobile.Chat.unlimited")
: plan.callLimitCount + t("Mobile.Chat.timesPerMonth")
}}</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="card-action">
<view
class="action-btn"
:class="{
'btn-current':
plan.id === activePlanId &&
currentSubscription?.status !== 'CANCELLED',
'btn-processing': processingId === plan.id,
'btn-disabled': plan.price <= 0,
}"
@tap="handleSelectPlan(plan)"
>
<text class="btn-text">{{ getActionText(plan) }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</modal-popup>
</template>
<script setup lang="uts">
import { ref, computed } from "vue";
import { useI18n } from "@/utils/i18n";
import {
apiGetAgentSubscriptionPlanList,
apiGetMySubscription,
} from "@/subpackages/servers/subscription";
import {
SystemSubscriptionPlan,
MySubscriptionItem,
} from "@/subpackages/types/interfaces/subscription";
import { formatNumber } from "@/utils/system";
import { useSubscriptionPurchase } from "@/subpackages/pages/my-subscriptions/hooks/useSubscriptionPurchase.uts";
import { getPeriodUnit } from "@/subpackages/pages/my-subscriptions/utils.uts";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
const { t } = useI18n();
const props = defineProps({
agentId: {
type: Number,
required: true,
},
currentPlanId: {
type: Number,
default: null,
},
currentPlanPrice: {
type: Number,
default: null,
},
});
const emit = defineEmits(["purchase-success", "update-visible"]);
const handleUpdateVisible = (visible: boolean) => {
emit("update-visible", visible);
};
const modalRef = ref<any>(null);
const loading = ref(false);
const plans = ref<SystemSubscriptionPlan[]>([]);
const { handlePaySubscription, processingId } = useSubscriptionPurchase();
const currentSubscription = ref<MySubscriptionItem | null>(null);
/**
* 动态计算生效中的 planId
*/
const activePlanId = computed((): number | null => {
if (currentSubscription.value != null) {
return currentSubscription.value!.planId;
}
return props.currentPlanId;
});
/**
* 动态计算生效中的 plan 价格,便于判断升级关系
*/
const activePlanPrice = computed((): number | null => {
if (
currentSubscription.value != null &&
currentSubscription.value!.plan != null
) {
return currentSubscription.value!.plan.price;
}
return props.currentPlanPrice;
});
/**
* 获取订阅状态文本
*/
const getSubscriptionStatusText = (status: string): string => {
if (status === "ACTIVE") {
return t("Mobile.MySubscriptions.stateActive");
}
if (status === "EXPIRED") {
return t("Mobile.MySubscriptions.stateExpired");
}
if (status === "CANCELLED") {
return t("Mobile.MySubscriptions.stateCancelled");
}
return "";
};
/**
* 计算操作按钮的文本(对齐 PC 端逻辑)
* - 当前使用中套餐(ACTIVE/EXPIRED): "当前套餐(续订)",支持点击续订
* - 当前套餐已取消(CANCELLED): "订阅"
* - 无订阅 / 价格 <= 当前使用金额: "订阅"
* - 价格 > 当前使用金额: "升级"
*/
const getActionText = (plan: SystemSubscriptionPlan): string => {
const curPlanId = activePlanId.value;
if (plan.id === curPlanId) {
if (currentSubscription.value != null) {
const status = currentSubscription.value!.status;
if (status === "CANCELLED") {
return t("Mobile.MySubscriptions.subscribeNow");
}
}
// 使用中/已过期的当前套餐:显示"当前套餐(续订)"
const currentPlanText = t("Mobile.MySubscriptions.currentPlanButton");
const renewText = t("Mobile.MySubscriptions.renewNow");
return `${currentPlanText}(${renewText})`;
}
if (curPlanId == null) {
return t("Mobile.MySubscriptions.subscribeNow");
}
// 使用当前生效套餐的金额作为判断基准
const currentPrice = activePlanPrice.value ?? 0;
if (plan.price <= currentPrice) {
return t("Mobile.MySubscriptions.subscribeNow");
}
return t("Mobile.MySubscriptions.upgradeNow");
};
/**
* 加载智能体订阅及套餐列表
*/
const fetchPlans = async () => {
if (props.agentId <= 0) return;
loading.value = true;
try {
// 1. 获取当前智能体订阅情况
const subRes = await apiGetMySubscription({
bizId: props.agentId.toString(),
bizType: "AGENT",
});
if (subRes.code === SUCCESS_CODE && subRes.data != null) {
currentSubscription.value = subRes.data!.currentSubscription;
} else {
currentSubscription.value = null;
}
// 2. 获取套餐列表
const res = await apiGetAgentSubscriptionPlanList({
agentId: props.agentId,
status: 1,
});
if (res.code === SUCCESS_CODE && res.data != null) {
plans.value = res.data!;
}
} catch (e) {
console.error("[AgentSubscriptionModal] 加载数据失败:", e);
} finally {
loading.value = false;
}
};
const handleSelectPlan = async (plan: SystemSubscriptionPlan) => {
if (plan.price <= 0) return;
await handlePaySubscription(plan.id);
};
/**
* 打开弹窗
*/
const open = () => {
modalRef.value?.open();
fetchPlans();
};
/**
* 关闭弹窗
*/
const close = () => {
modalRef.value?.close();
};
defineExpose({
open,
close,
});
</script>
<style lang="scss" scoped>
.agent-sub-container {
border-radius: 24rpx;
display: flex;
flex-direction: column;
max-height: 70vh;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 0;
.loading-icon,
.empty-icon {
font-size: 56rpx;
margin-bottom: 16rpx;
}
.loading-text,
.empty-text {
font-size: 26rpx;
color: #999;
}
}
.plans-scroll {
flex: 1;
width: 100%;
}
.plans-list {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 4rpx;
}
.plan-card {
position: relative;
background-color: #ffffff;
border: 2rpx solid #eaeaea;
border-radius: 24rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.03);
transition: all 0.25s ease;
&.plan-current {
border-color: #007aff;
background-color: #f7faff;
box-shadow: 0 8rpx 32rpx rgba(0, 122, 255, 0.08);
}
&.plan-expired {
border-color: #86909c;
background-color: #f2f3f5;
box-shadow: 0 8rpx 32rpx rgba(134, 144, 156, 0.08);
}
&.plan-cancelled {
border-color: #f53f3f;
background-color: #fff7f7;
box-shadow: 0 8rpx 32rpx rgba(245, 63, 63, 0.08);
}
}
.card-badge-row {
position: absolute;
top: -2rpx;
right: -2rpx;
z-index: 10;
display: flex;
flex-direction: row;
.badge {
height: 48rpx;
padding: 0 24rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
border-radius: 0 24rpx 0 32rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
&-active {
background: linear-gradient(135deg, #007aff, #0056b3);
}
&-expired {
background: linear-gradient(135deg, #86909c, #4e5969);
}
&-cancelled {
background: linear-gradient(135deg, #f53f3f, #b71c1c);
}
&-hot {
background: linear-gradient(135deg, #ff9500, #ff5e00);
}
.badge-text {
font-size: 20rpx;
color: #ffffff;
font-weight: bold;
line-height: 1;
}
}
}
.card-main {
flex: 1;
margin-bottom: 24rpx;
}
.plan-info-header {
display: flex;
flex-direction: column;
gap: 8rpx;
margin-bottom: 16rpx;
padding-right: 120rpx;
.plan-name {
font-size: 32rpx;
color: #333333;
font-weight: bold;
}
.plan-desc {
font-size: 22rpx;
color: #777777;
line-height: 1.4;
}
}
.plan-price-box {
display: flex;
flex-direction: row;
align-items: baseline;
margin-bottom: 24rpx;
.price-symbol {
font-size: 28rpx;
font-weight: bold;
margin-right: 4rpx;
}
.price-value {
font-size: 48rpx;
font-weight: 800;
}
.price-period {
font-size: 26rpx;
color: #888888;
margin-left: 8rpx;
}
}
.plan-limits {
display: flex;
flex-direction: column;
gap: 12rpx;
background-color: #f9f9f9;
border-radius: 16rpx;
padding: 16rpx 20rpx;
.limit-item {
display: flex;
flex-direction: row;
align-items: center;
font-size: 24rpx;
.limit-bullet {
color: #007aff;
margin-right: 12rpx;
font-weight: bold;
}
.limit-label {
color: #666666;
}
.limit-val {
color: #333333;
font-weight: 600;
}
}
}
.card-action {
width: 100%;
.action-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 76rpx;
background: #1e6deb;
border-radius: 12rpx;
box-shadow: none;
box-sizing: border-box;
&:active {
transform: scale(0.98);
opacity: 0.9;
}
&.btn-current {
background: #efebe6;
.btn-text {
color: #5c564f;
}
}
&.btn-processing {
opacity: 0.6;
}
&.btn-disabled {
background: #f5f5f5;
box-shadow: none;
pointer-events: none;
.btn-text {
color: #c0c4cc;
}
&:active {
transform: none;
opacity: 1;
}
}
.btn-text {
font-size: 26rpx;
color: #fff;
font-weight: 600;
line-height: 26rpx;
}
}
}
</style>

View File

@@ -0,0 +1,235 @@
<template>
<view
v-if="node.type === 'folder'"
class="folder-item"
:style="`margin-left: ${level * 16}rpx`"
>
<view
class="folder-header"
@tap="handleToggleFolder"
>
<!-- 文件夹展开/收起图标 -->
<text class="folder-caret iconfont icon-a-Chevronright"
:class="{ expanded: isExpanded }"></text>
<view class="folder-name">{{ node.name }}</view>
</view>
<view v-if="isExpanded && node.children && !!node.children.length" class="file-list">
<file-tree-node
v-for="child in node.children"
:key="child.id"
:node="child"
:level="level + 1"
:expanded-folders="expandedFolders"
:selected-file-id="selectedFileId"
@toggle-folder="handleChildToggleFolder"
@select-file="handleChildSelectFile"
/>
</view>
</view>
<view
v-else
class="file-item"
:class="{ 'active-file': isSelected, 'hidden-file': node.name.startsWith('.') }"
:style="`margin-left: ${level * 8}rpx`"
@tap.stop="handleSelectFile"
>
<view class="file-icon-wrapper">
<image
class="file-icon"
:src="fileIcon"
mode="aspectFit"
alt=""
@error="handleImageError"
/>
</view>
<view class="file-name">{{ node.name }}</view>
</view>
</template>
<script setup lang="uts">
import { computed } from 'vue'
import type { FileNode } from '@/types/interfaces/agent'
import FileTreeNode from './file-tree-node.uvue'
import { getFileIcon } from './fileIconHelper.uts'
import iconDefault from '@/static/filetree/icon_default.svg'
interface Props {
node: FileNode
level: number
expandedFolders: Set<string>
selectedFileId: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
toggleFolder: [folderId: string]
selectFile: [fileNode: FileNode]
}>()
/**
* 是否展开
*/
const isExpanded = computed(() => {
return props.expandedFolders.has(props.node.id)
})
/**
* 是否选中
*/
const isSelected = computed(() => {
return props.selectedFileId === props.node.id
})
/**
* 文件图标路径
*/
const fileIcon = computed<string>(() => {
if (props.node?.type === 'file' || props.node?.isLink) {
return getFileIcon(props.node.name)
}
return iconDefault
})
/**
* 图片加载错误处理
*/
const handleImageError = (e: any) => {
console.error('Image load error:', fileIcon.value, e)
}
/**
* 切换文件夹展开/收起
*/
const handleToggleFolder = () => {
if (props.node.type === 'folder') {
emit('toggleFolder', props.node.id)
}
}
/**
* 选择文件
*/
const handleSelectFile = () => {
if (props.node.type === 'file' && !props.node.name.startsWith('.')) {
emit('selectFile', props.node)
}
}
/**
* 处理子组件的切换文件夹事件
*/
const handleChildToggleFolder = (folderId: string) => {
emit('toggleFolder', folderId)
}
/**
* 处理子组件的选择文件事件
*/
const handleChildSelectFile = (fileNode: FileNode) => {
emit('selectFile', fileNode)
}
</script>
<style scoped lang="scss">
@import '@/static/iconfont/iconfont.css';
.folder-item {
padding: 0rpx 0 0 32rpx;
white-space: nowrap;
display: block;
.folder-header {
display: flex;
align-items: center;
flex-direction: row;
padding: 16rpx 0;
cursor: pointer;
user-select: none;
min-height: 40rpx;
white-space: nowrap;
.folder-caret {
font-size: 18rpx;
color: #5e5e5b;
margin-right: 12rpx;
transition: transform 0.2s;
flex-shrink: 0;
// display: inline-block;
// width: 18rpx;
// text-align: center;
// line-height: 1;
&.expanded {
transform: rotate(90deg);
}
}
// .folder-icon {
// font-size: 28rpx;
// color: #8b9dc3;
// margin-right: 6rpx;
// display: inline-block;
// line-height: 1;
// }
.folder-name {
font-size: 26rpx;
color: #5e5e5b;
line-height: 1.4;
white-space: nowrap;
flex-shrink: 0;
}
}
.file-list {
margin-left: 0;
}
}
.file-item {
display: flex;
align-items: center;
flex-direction: row;
padding: 16rpx 32rpx;
color: #5e5e5b;
cursor: pointer;
user-select: none;
min-height: 40rpx;
white-space: nowrap;
&.active-file {
background-color: #f0f0f0;
border-radius: 4rpx;
}
&.hidden-file {
opacity: 0.6;
}
.file-icon-wrapper {
width: 28rpx;
height: 28rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12rpx;
flex-shrink: 0;
.file-icon {
width: 28rpx;
height: 28rpx;
display: block;
color: #5e5e5b;
}
}
.file-name {
font-size: 26rpx;
color: #5e5e5b;
line-height: 1.4;
white-space: nowrap;
flex-shrink: 0;
}
}
</style>

View File

@@ -0,0 +1,421 @@
<template>
<drawer-popup
ref="popupFileTreeRef"
direction="bottom"
height="85vh"
:title="t('Mobile.Common.workspace')"
@update-visible="updateVisible"
>
<view class="file-tree">
<view class="file-tree-wrapper" ref="fileTreeWrapperRef">
<!-- <text class="test-long-text">噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个</text> -->
<!-- 加载中, 刷新时,不显示加载图标 -->
<template v-if="isLoading && !files?.length">
<view class="file-tree-loading">
<image
class="icon-loading-image"
src="@/static/assets/icon_loading.svg"
mode="widthFix"
alt=""
/>
</view>
</template>
<!-- 有文件时,显示文件树 -->
<template v-else-if="!!files?.length">
<file-tree-node
v-for="node in files"
:key="node.id"
:node="node"
:level="0"
:expanded-folders="expandedFolders"
:selected-file-id="selectedFileId"
@toggle-folder="handleToggleFolder"
@select-file="handleSelectFile"
/>
</template>
<!-- 没有文件时,显示空状态 -->
<template v-else>
<view class="file-tree-empty">
<text>{{ t("Mobile.FileTree.noFiles") }}</text>
</view>
</template>
</view>
<view
class="download-button"
v-if="!isLoading && files?.length > 0"
:style="{
bottom: buttonPosition.bottom + 'rpx',
right: buttonPosition.right + 'rpx',
}"
@click="handleDownload"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<text class="download-icon iconfont icon-a-Arrowdown"></text>
</view>
</view>
</drawer-popup>
</template>
<script setup lang="uts">
import { onMounted } from "vue";
import DrawerPopup from "@/components/drawer-popup/drawer-popup.uvue";
import type { FileNode } from "@/types/interfaces/agent";
import FileTreeNode from "./file-tree-node.uvue";
import { apiDownloadAllFiles } from "@/servers/agentDev.uts";
import { API_BASE_URL } from "@/constants/config";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
// #ifdef H5 || WEB
import { exportWholeProjectZipH5 } from "@/subpackages/utils/fileTree";
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
import { downloadFileInMiniProgram } from "@/subpackages/utils/fileTree";
// #endif
// 文件树弹窗引用
const popupFileTreeRef = ref<any>(null);
// 文件树包装器引用
const fileTreeWrapperRef = ref<any>(null);
// 已展开的文件夹ID集合
const expandedFolders = ref<Set<string>>(new Set());
// 选中的文件ID
const selectedFileId = ref<string>("");
// 下载按钮位置
const buttonPosition = ref<{ bottom: number; right: number }>({
bottom: 50,
right: 50,
});
// 拖拽相关状态
const isDragging = ref<boolean>(false);
const dragStartPos = ref<{ x: number; y: number }>({ x: 0, y: 0 });
const buttonStartPos = ref<{ bottom: number; right: number }>({
bottom: 50,
right: 50,
});
const props = withDefaults(
defineProps<{
cId: number;
files: FileNode[];
isLoading: boolean;
}>(),
{
cId: 0,
files: [],
isLoading: false,
},
);
const emit = defineEmits(["update-visible"]);
// 处理文件树弹窗可见性变化
const updateVisible = (value: boolean) => {
emit("update-visible", value);
};
// 导出项目
const handleExportProject = async () => {
// 检查项目ID是否有效
if (!props.cId) {
uni.showToast({
title: t("Mobile.FileTree.invalidConversationId"),
icon: "none",
});
return;
}
try {
uni.showLoading({ title: t("Mobile.FileTree.preparingDownload") });
// #ifdef H5 || WEB
// H5 环境:使用 blob 下载
const result = await apiDownloadAllFiles(props.cId);
const filename = `chat-${props.cId}.zip`;
await exportWholeProjectZipH5(
result.data as Blob,
result.headers,
filename,
);
uni.hideLoading();
uni.showToast({
title: t("Mobile.FileTree.exportSuccess"),
icon: "success",
});
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
// 小程序环境直接使用下载地址后端已打包成zip
let downloadUrl = `${API_BASE_URL}/api/computer/static/download-all-files?cId=${props.cId}`;
const filename = `chat-${props.cId}.zip`;
// 下载并保存zip文件返回保存路径函数内部已弹窗提示用户
const savedFilePath = await downloadFileInMiniProgram(
downloadUrl,
filename,
);
console.log("文件保存路径:", savedFilePath);
// #endif
} catch (error) {
uni.hideLoading();
// 改进错误处理,兼容不同的错误格式
const errorMessage =
(error as any)?.message || t("Mobile.FileTree.exportUnknownError");
uni.showToast({
title: t("Mobile.FileTree.exportFailed", { message: errorMessage }),
icon: "none",
});
console.error("导出失败:", error);
}
};
/**
* 下载文件
*/
const handleDownload = () => {
if (isDragging.value) {
return; // 如果正在拖拽,不触发下载
}
handleExportProject();
};
/**
* 加载按钮位置
*/
const loadButtonPosition = () => {
try {
const saved = uni.getStorageSync("file-tree-download-button-position");
if (saved) {
buttonPosition.value = saved;
}
} catch (e) {
console.error("加载按钮位置失败:", e);
}
};
/**
* 保存按钮位置
*/
const saveButtonPosition = () => {
try {
uni.setStorageSync(
"file-tree-download-button-position",
buttonPosition.value,
);
} catch (e) {
console.error("保存按钮位置失败:", e);
}
};
/**
* 触摸开始
*/
const handleTouchStart = (e: any) => {
const touch = e.touches?.[0];
if (!touch) return;
// 使用 clientX/clientY 获取触摸坐标
dragStartPos.value = {
x: touch.clientX,
y: touch.clientY,
};
buttonStartPos.value = { ...buttonPosition.value };
isDragging.value = false;
};
/**
* 触摸移动
*/
const handleTouchMove = (e: any) => {
const touch = e.touches?.[0];
if (!touch) return;
const currentX = touch.clientX;
const currentY = touch.clientY;
const deltaX = currentX - dragStartPos.value.x;
const deltaY = currentY - dragStartPos.value.y;
// 如果移动距离超过10px认为是拖拽
if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {
isDragging.value = true;
}
if (isDragging.value) {
// 获取系统信息,计算屏幕尺寸
const systemInfo = uni.getSystemInfoSync();
const screenWidth = systemInfo.windowWidth;
const screenHeight = systemInfo.windowHeight;
// 将px转换为rpx (1rpx = screenWidth / 750)
const rpxRatio = screenWidth / 750;
// 计算新位置
// deltaX: 向右移动为正,所以 right 应该减小
// deltaY: 向下移动为正,所以 bottom 应该减小(因为 bottom 是从底部向上的距离)
let newRight = buttonStartPos.value.right - deltaX / rpxRatio;
let newBottom = buttonStartPos.value.bottom - deltaY / rpxRatio;
// 限制在屏幕范围内
const buttonWidth = 120; // 按钮宽度 120rpx
const buttonHeight = 120; // 按钮高度 120rpx
const minRight = 0;
const maxRight = screenWidth / rpxRatio - buttonWidth;
const minBottom = 0;
const maxBottom = screenHeight / rpxRatio - buttonHeight;
newRight = Math.max(minRight, Math.min(maxRight, newRight));
newBottom = Math.max(minBottom, Math.min(maxBottom, newBottom));
buttonPosition.value = {
right: newRight,
bottom: newBottom,
};
}
};
/**
* 触摸结束
*/
const handleTouchEnd = () => {
if (isDragging.value) {
saveButtonPosition();
}
isDragging.value = false;
};
// 组件挂载时加载位置
onMounted(() => {
loadButtonPosition();
});
/**
* 切换文件夹展开/收起
*/
const handleToggleFolder = (folderId: string) => {
const newExpanded = new Set(expandedFolders.value);
if (newExpanded.has(folderId)) {
newExpanded.delete(folderId);
} else {
newExpanded.add(folderId);
}
expandedFolders.value = newExpanded;
};
/**
* 选择文件
*/
const handleSelectFile = (fileNode: FileNode) => {
selectedFileId.value = fileNode.id;
if (fileNode?.isLink) {
uni.showToast({
title: t("Mobile.FileTree.linkFileNotSupported"),
icon: "none",
});
return;
}
uni.navigateTo({
url:
"/subpackages/pages/file-preview-page/file-preview-page?cId=" +
props.cId +
"&fileProxyUrl=" +
encodeURIComponent(fileNode.fileProxyUrl),
});
};
// 打开文件树弹窗
const open = () => {
popupFileTreeRef.value?.open();
};
// 关闭文件树弹窗
const close = () => {
popupFileTreeRef.value?.close();
};
// 暴露文件树弹窗引用
defineExpose({
open,
close,
});
</script>
<style scoped lang="scss">
.file-tree {
position: relative;
height: 100%;
width: 100%;
overflow-x: scroll;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
.file-tree-wrapper {
display: inline-block;
padding-bottom: 20rpx;
width: max-content;
min-width: 100%;
}
.test-long-text {
white-space: nowrap;
}
.file-tree-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
.icon-loading-image {
width: 48rpx;
height: 48rpx;
margin-right: 10rpx;
-webkit-touch-callout: none !important;
-webkit-user-select: none !important;
user-select: none !important;
}
}
.file-tree-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 28rpx;
color: #999;
}
.download-button {
position: fixed;
width: 120rpx;
height: 120rpx;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
transition: background-color 0.2s;
touch-action: none;
&:active {
background-color: rgba(0, 0, 0, 0.8);
}
.download-icon {
font-size: 48rpx;
color: #fff;
line-height: 1;
}
}
}
</style>

View File

@@ -0,0 +1,84 @@
// 导入所有文件图标
import iconDefault from '@/static/filetree/icon_default.svg'
import iconTsx from '@/static/filetree/icon_tsx.svg'
import iconTs from '@/static/filetree/icon_ts.svg'
import iconJs from '@/static/filetree/icon_js.svg'
import iconCss from '@/static/filetree/icon_css.svg'
import iconJson from '@/static/filetree/icon_json.svg'
import iconHtml from '@/static/filetree/icon_html.svg'
import iconMd from '@/static/filetree/icon_md.svg'
import iconPy from '@/static/filetree/icon_py.svg'
import iconSql from '@/static/filetree/icon_sql.svg'
import iconPng from '@/static/filetree/icon_png.svg'
import iconSvg from '@/static/filetree/icon_svg.svg'
/**
* 根据文件名获取文件图标路径
* @param fileName 文件名
* @returns 图标路径
*/
export function getFileIcon(fileName: string): string {
if (!fileName || fileName.length === 0) {
return iconDefault
}
const ext = fileName.split('.').pop()?.toLowerCase() || ''
// TypeScript 文件
if (ext === 'tsx') {
return iconTsx
}
if (ext === 'ts') {
return iconTs
}
// JavaScript 文件
if (['js', 'jsx', 'mjs', 'cjs'].includes(ext)) {
return iconJs
}
// CSS 文件
if (['css', 'less', 'scss', 'sass'].includes(ext)) {
return iconCss
}
// JSON 文件
if (['json', 'jsonc'].includes(ext)) {
return iconJson
}
// HTML 文件
if (['html', 'htm'].includes(ext)) {
return iconHtml
}
// Markdown 文件
if (['md', 'markdown'].includes(ext)) {
return iconMd
}
// Python 文件
if (ext === 'py') {
return iconPy
}
// SQL 文件
if (['sql', 'mysql', 'pgsql'].includes(ext)) {
return iconSql
}
// 图片文件
if (ext === 'png') {
return iconPng
}
if (ext === 'svg') {
return iconSvg
}
if (['jpg', 'jpeg', 'gif', 'bmp', 'webp', 'ico', 'tiff'].includes(ext)) {
return iconPng
}
// 默认文件图标
return iconDefault
}

View File

@@ -0,0 +1,230 @@
<template>
<view class="more-info-container">
<!-- 头像区域 -->
<view class="avatar-section">
<view class="avatar-wrapper">
<image
class="avatar"
:src="agentInfo?.icon"
mode="aspectFill"
:alt="t('Mobile.Common.avatarAlt')"
/>
</view>
</view>
<!-- 标题区域 -->
<view class="title-section">
<text class="title text-ellipsis">{{ agentInfo?.name }}</text>
</view>
<!-- 作者信息 -->
<view class="author-section">
<text class="author-label">{{ t("Mobile.Common.from") }}</text>
<view class="author-avatar">
<text class="author-initial">{{ authorInitial }}</text>
</view>
<text class="author-name">{{
agentInfo?.publishUser?.nickName || agentInfo?.publishUser?.userName
}}</text>
</view>
<!-- 统计数据 -->
<view class="stats-section">
<view class="stat-item">
<text class="iconfont icon-User" />
<text class="stat-number">{{ agentInfo?.statistics?.userCount }}</text>
</view>
<view class="stat-item">
<text class="iconfont icon-message" />
<text class="stat-number">{{ agentInfo?.statistics?.convCount }}</text>
</view>
<view class="stat-item">
<text class="iconfont icon-Star" />
<text class="stat-number">{{
agentInfo?.statistics?.collectCount
}}</text>
</view>
</view>
<!-- 描述文本 -->
<view class="description-section">
<text class="description-text">
{{ agentInfo?.description }}
</text>
</view>
</view>
</template>
<script lang="uts" setup>
import type { AgentDetailDto } from "@/types/interfaces/agent";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
// 组件逻辑
interface Props {
agentInfo: AgentDetailDto;
}
// 接收组件属性,设置默认值
const props = withDefaults(defineProps<Props>(), {});
const authorInitial = computed(() => {
const name =
props.agentInfo?.publishUser?.nickName ||
props.agentInfo?.publishUser?.userName ||
"";
const first = String(name).trim().charAt(0);
return first || "#";
});
</script>
<style lang="scss" scoped>
// 变量定义
$primary-color: #15171f;
$secondary-color: rgba(21, 23, 31, 0.7);
$muted-color: rgba(21, 23, 31, 0.5);
$accent-color: #6366f1;
$bg-primary: #fff;
$bg-avatar: #fff3cd;
$bg-author: #dadbff;
// 阴影层级
$shadow-light: 0 2rpx 8rpx rgba(232, 229, 255, 0.6);
$shadow-heavy: 0 4rpx 12rpx rgba(255, 243, 205, 0.4);
.more-info-container {
padding: 40rpx 32rpx;
background-color: $bg-primary;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
position: relative;
z-index: 1;
border-radius: 16rpx;
.avatar-section {
margin-bottom: 32rpx;
.avatar-wrapper {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background-color: $bg-avatar;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
z-index: 2;
box-shadow: $shadow-heavy;
position: relative;
.avatar {
width: 106%;
height: 106%;
position: absolute;
left: -3%;
top: -3%;
}
}
}
.title-section {
margin-bottom: 24rpx;
width: 100%;
.title {
font-size: 48rpx;
font-weight: 600;
color: $primary-color;
line-height: 64rpx;
text-align: center;
}
}
.author-section {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
gap: 12rpx;
.author-label {
font-size: 28rpx;
line-height: 44rpx;
color: $muted-color;
}
.author-avatar {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background-color: $bg-author;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 2;
box-shadow: $shadow-light;
.author-initial {
font-size: 24rpx;
color: $accent-color;
font-weight: 500;
}
}
.author-name {
font-size: 28rpx;
line-height: 44rpx;
color: $primary-color;
}
}
.stats-section {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 48rpx;
margin-bottom: 40rpx;
position: relative;
z-index: 1;
padding: 24rpx 32rpx;
border-radius: 12rpx;
.stat-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8rpx;
.iconfont {
font-size: 32rpx;
color: rgba(21, 23, 31, 0.5);
}
.stat-number {
font-size: 32rpx;
line-height: 32rpx;
color: $muted-color;
font-weight: 500;
}
}
}
.description-section {
// max-width: 600rpx;
.description-text {
font-size: 28rpx;
line-height: 44rpx;
color: $secondary-color;
text-align: center;
}
}
}
</style>

View File

@@ -0,0 +1,289 @@
<template>
<drawer-popup
ref="popup"
:title="t('Mobile.Conversation.more')"
direction="bottom"
:auto-height="true"
default-height="15%"
max-height="40%"
>
<view class="menu-list">
<view class="menu-item" @click="handleCollect">
<text class="iconfont icon-Star" v-if="!agentInfo?.collect" />
<text class="iconfont icon-Star-fill" v-else />
<text class="menu-text text-ellipsis">{{
t("Mobile.Conversation.favorite")
}}</text>
</view>
<view class="menu-item" @click="handleShare">
<text class="iconfont icon-Share" />
<text class="menu-text text-ellipsis">{{
t("Mobile.Conversation.share")
}}</text>
</view>
<view
class="menu-item"
v-if="agentInfo?.paymentRequired === true && isSubscriptionEnabled"
@click="handleMySubscription"
>
<text class="iconfont icon-Heart" />
<text class="menu-text text-ellipsis">{{
t("Mobile.Conversation.mySubscription")
}}</text>
</view>
<view class="menu-item" @click="handleMoreInfo">
<text class="iconfont icon-Info" />
<text class="menu-text text-ellipsis">{{
t("Mobile.Conversation.moreInfo")
}}</text>
</view>
<template v-if="isTaskAgent && hasHistory">
<view class="menu-item" @click="handleRestartAgent">
<text class="iconfont icon-restart" />
<text class="menu-text text-ellipsis">{{
t("Mobile.Sandbox.restartAgent")
}}</text>
</view>
<view class="menu-item" @click="handleRestartSandbox">
<text class="iconfont icon-restart_agent" />
<text class="menu-text text-ellipsis">{{
currentSandboxId === "-1"
? t("Mobile.Sandbox.restartCloudComp")
: t("Mobile.Sandbox.restartClient")
}}</text>
</view>
</template>
</view>
</drawer-popup>
<drawer-popup
ref="popupMore"
direction="bottom"
height="85vh"
:show-header="true"
>
<more-info v-if="agentInfo" :agent-info="agentInfo" />
<related-conversation
v-if="agentInfo"
:agent-info="agentInfo"
@view-all="handleViewAll"
/>
</drawer-popup>
<drawer-popup
ref="popupViewAll"
:title="t('Mobile.Conversation.allConversations')"
direction="bottom"
height="85vh"
>
<!-- 根据智能体id动态获取历史记录 -->
<agent-conversation-history
v-if="agentInfo"
:agent-id="agentInfo.agentId"
/>
</drawer-popup>
</template>
<script setup lang="uts">
import { ref } from "vue";
import MoreInfo from "../more-info/more-info.uvue";
import RelatedConversation from "../related-conversation/related-conversation.uvue";
import type { AgentDetailDto } from "@/types/interfaces/agent";
import type { ConversationInfo } from "@/types/interfaces/conversationInfo";
import AgentConversationHistory from "../agent-conversation-history/agent-conversation-history.uvue";
import { useI18n } from "@/utils/i18n";
import { TENANT_CONFIG_INFO } from "@/constants/home.constants";
import type { TenantConfigInfo } from "@/types/interfaces/login";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { apiTenantConfig } from "@/servers/account";
const { t } = useI18n();
interface Props {
agentInfo: AgentDetailDto;
isTaskAgent?: boolean;
hasHistory?: boolean;
currentSandboxId?: string;
}
// 接收组件属性,设置默认值
const props = withDefaults(defineProps<Props>(), {
isTaskAgent: false,
hasHistory: false,
currentSandboxId: "",
});
// 是否开启订阅
const isSubscriptionEnabled = ref<boolean>(false);
const checkSubscriptionEnable = async () => {
const tenantConfigInfoString = uni.getStorageSync(TENANT_CONFIG_INFO);
if (tenantConfigInfoString) {
try {
const tenantConfig = JSON.parse(
tenantConfigInfoString,
) as TenantConfigInfo;
isSubscriptionEnabled.value = tenantConfig.enableSubscription === 1;
} catch (error) {
console.error("解析租户配置失败:", error);
}
}
try {
const res = await apiTenantConfig();
if (res.code === SUCCESS_CODE && res.data != null) {
const latestConfig = res.data!;
isSubscriptionEnabled.value = latestConfig.enableSubscription === 1;
uni.setStorageSync(TENANT_CONFIG_INFO, JSON.stringify(latestConfig));
}
} catch (e) {
console.error("同步最新租户配置失败:", e);
}
};
checkSubscriptionEnable();
// 定义事件
const emit = defineEmits([
"collect",
"restartAgent",
"restartSandbox",
"mySubscription",
]);
const popup = ref<any>(null);
const popupMore = ref<any>(null);
const popupViewAll = ref<any>(null);
const chatList = ref<ConversationInfo[]>([]);
// 打开抽屉
const open = () => {
checkSubscriptionEnable();
popup.value?.open();
};
// 关闭抽屉
const close = () => {
popup.value?.close();
};
// 收藏功能
const handleCollect = () => {
close();
emit("collect");
};
// 订阅功能
const handleMySubscription = () => {
close();
emit("mySubscription");
};
// 分享功能
const handleShare = () => {
// 获取当前页面URL
let currentUrl = "";
// #ifdef H5
currentUrl = window.location.href;
// #endif
// #ifdef MP-WEIXIN
// 微信小程序中获取当前页面路径
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
currentUrl = `/${currentPage.route}`;
if (currentPage.options && Object.keys(currentPage.options).length > 0) {
const params = Object.keys(currentPage.options)
.map((key) => `${key}=${currentPage.options[key]}`)
.join("&");
currentUrl += `?${params}`;
}
// #endif
// 复制到剪贴板
uni.setClipboardData({
data: currentUrl,
success: () => {
uni.showToast({
title: t("Mobile.Link.copyAndOpen"),
icon: "success",
duration: 2000,
});
},
fail: (err) => {
console.error("复制失败:", err);
uni.showToast({
title: t("Mobile.Common.copyFailed"),
icon: "error",
duration: 2000,
});
},
});
close();
};
// 更多信息功能
const handleMoreInfo = () => {
close();
popupMore.value?.open();
};
// 重启智能体
const handleRestartAgent = () => {
close();
emit("restartAgent");
};
// 重启容器环境
const handleRestartSandbox = () => {
close();
emit("restartSandbox");
};
const handleViewAll = (data: ConversationInfo[]) => {
chatList.value = data;
popupViewAll.value?.open();
};
onLoad(() => {});
defineExpose({
open,
close,
popupMore,
});
</script>
<style lang="scss" scoped>
.menu-list {
.menu-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 30rpx;
transition: background-color 0.2s;
border-radius: 8rpx;
&:active {
background-color: rgba(12, 20, 102, 0.04);
}
.iconfont {
font-size: 40rpx;
color: #333;
}
.icon-Star-fill {
color: #faad14;
}
.menu-text {
margin-left: 24rpx;
font-size: 32rpx;
line-height: 48rpx;
color: rgba(21, 23, 31, 0.7);
flex: 1;
}
}
}
</style>

View File

@@ -0,0 +1,443 @@
<template>
<view class="variables-box flex flex-col" v-if="variables?.length">
<!-- header -->
<view class="header">
<text class="title">{{ t("Mobile.Conversation.settingsTitle") }}</text>
<text class="iconfont icon-a-Chevrondown down-icon" :class="{'rotate-90': !isOpen}" @click="handleOpenClose" />
</view>
<!-- 内容区域 -->
<view :class="{'close-form': !isOpen, 'conversation-container': isOpen}">
<view class="form-box">
<view class="uni-form-item" v-for="item in variables" :key="item.key">
<!-- 名称 -->
<view class="form-item-title">
<text class="display-name">{{ item.displayName }}</text>
<text v-if="item.require" class="required">{{
t("Mobile.Common.requiredMark")
}}</text>
</view>
<!-- textarea -->
<textarea
v-if="item.inputType === InputTypeEnum.Paragraph"
:placeholder="item.description || t('Mobile.Common.pleaseInput')"
class="uni-input"
:class="{'disabled-style': disabled}"
:disabled="disabled"
:value="formSourceData[item.name]"
placeholder-class="uni-placeholder"
:maxlength="-1"
@input="(e) => handleChangeInput(e, item)" />
<!-- 级联下拉选择器(单选、多选) -->
<view
v-else-if="item.inputType === InputTypeEnum.Select || item.inputType === InputTypeEnum.MultipleSelect"
class="uni-input flex flex-row items-center content-between"
@click="handleSelect(item)"
>
<scroll-view
v-if="formSourceData[item.name]?.length"
class="flex-1 flex flex-row items-center"
direction="horizontal"
:scroll-with-animation="true"
:show-scrollbar="false"
scroll-x>
<!-- 循环渲染 -->
<template v-for="(info, index) in formSourceData[item.name]" :key="info">
<!-- 多选样式 -->
<view class="multiple-box" v-if="item.inputType === InputTypeEnum.MultipleSelect">
<text :class="{'disabled-style': disabled}">{{`${info?.label || info}`}}</text>
</view>
<!-- 单选样式 -->
<template v-else>
<text :class="{'disabled-style': disabled}">{{`${info?.label || info}`}}</text>
<text
:class="{'disabled-style': disabled}"
v-if="index !== formSourceData[item.name].length - 1"
>
{{ t("Mobile.Common.optionSeparator") }}
</text>
</template>
</template>
</scroll-view>
<text v-else class="uni-placeholder">{{
t("Mobile.Common.pleaseSelect")
}}</text>
<text class="iconfont icon-a-Chevrondown down-icon ml-10" />
</view>
<!-- 输入框 -->
<input
v-else
:disabled="disabled"
:value="formSourceData[item.name]"
:placeholder="item.description || t('Mobile.Common.pleaseInput')"
:type="item.inputType === InputTypeEnum.Number ? 'number' : 'text'"
class="uni-input"
:class="{'disabled-style': disabled}"
placeholder-class="uni-placeholder"
@input="(e) => handleChangeInput(e, item)"
/>
</view>
</view>
<view v-if="disabled" class="desc-box">
<text class="desc">{{
t("Mobile.Conversation.settingsLocked")
}}</text>
</view>
<!-- 单选级联选择器 -->
<l-cascader
v-if="visible"
:visible="visible"
v-model="cascaderValue"
:title="t('Mobile.Common.pleaseSelect')"
:options="options"
@close="handleCancelSelect"
@change="onChange" />
<!-- 多选级联选择器 -->
<l-cascader
v-if="visibleMultiple"
:visible="visibleMultiple"
v-model:multipleValue="cascaderMultipleValue"
:multiple="isMultiple"
:title="t('Mobile.Common.pleaseSelect')"
:options="options"
:confirmBtn="t('Mobile.Common.ok')"
@close="handleCancelSelect"
@finish="handleFinish" />
</view>
</view>
</template>
<script setup lang="uts">
import { BindConfigWithSub } from '@/types/interfaces/common';
import { InputTypeEnum } from '@/types/enums/agent';
import lCascader from '@/uni_modules/lime-cascader/components/l-cascader/l-cascader.uvue';
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
const props = defineProps({
variables: {
type: Array as PropType<BindConfigWithSub[]>,
default: () => [],
},
userFillVariables: {
type: Object,
default: () => {},
},
isFilled: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
// 是否打开表单
const isOpen = ref<boolean>(true);
// 是否显示单选级联选择器
const visible = ref<boolean>(false);
// 是否显示多选级联选择器
const visibleMultiple = ref<boolean>(false);
// 是否多选
const isMultiple = ref<boolean>(false);
// 级联选择器选项
const options = ref<any[]>([]);
// 已选择项的值(单选模式)
const cascaderValue = ref<string>('');
// 已选择项的值(多选模式)
const cascaderMultipleValue = ref<string[]>([]);
// 当前选择项的名称
const currentName = ref<string>('');
// 表单数据
const formData = reactive({});
// 已选择项的源数据
const formSourceData = reactive({});
const emit = defineEmits(['toggleWholeDisabled'])
defineExpose({
variableParams: formData
})
/**
* 监听智能体用户已填写的变量参数
*/
watch(() => props.userFillVariables, (newVal) => {
// 判断newVal是否为空对象
const isEmptyObject = newVal && typeof newVal === 'object' && Object.keys(newVal).length === 0
if (isEmptyObject) {
// 如果是空对象清空formSourceData的所有属性保持响应性
for (const key in formSourceData) {
delete formSourceData[key]
}
// 如果是空对象清空formData的所有属性保持响应性
for (const key in formData) {
delete formData[key]
}
} else if (newVal && typeof newVal === 'object') {
// 如果不是空对象更新formSourceData
Object.assign(formSourceData, newVal)
Object.assign(formData, newVal)
} else {
// 如果newVal为null或undefined也清空formSourceData
for (const key in formSourceData) {
delete formSourceData[key]
}
// 如果是空对象清空formData的所有属性保持响应性
for (const key in formData) {
delete formData[key]
}
}
}, { deep: true, immediate: true })
// 必填变量参数name列表
const requiredNameList = computed(() => {
return props.variables
?.filter((item: BindConfigWithSub) => !item.systemVariable && item.require)
?.map((item: BindConfigWithSub) => item.name) || []
})
// 监听必填参数列表和变量参数变化
watch([requiredNameList, formData], () => {
// 没有必填参数时,不禁用发送按钮
if (requiredNameList.value?.length > 0) {
let isSameName = true
for (const item of requiredNameList.value) {
if (!formData[item] || formData[item]?.length === 0) {
isSameName = false
break
}
}
// 未填写必填参数,禁用发送按钮
emit('toggleWholeDisabled', !isSameName)
}
}, { deep: true, immediate: true })
/**
* 级联选择器选择后
* @param selectedPaths 选中的路径数组 输出: [['440000', '440300', '440305'], ['440000', '440300', '440306']]
* @param selectedOptions 选中的源数据 // 输出:
* [
* { value: '440305', label: '南山区', ... },
* { value: '440306', label: '龙岗区', ... }
* ]
*/
const handleFinish = (selectedPaths: string[][], selectedOptions: UTSJSONObject[]) => {
// 输出: [['440000', '440300', '440305'], ['440000', '440300', '440306']]
formData[currentName.value] = selectedPaths || []
formSourceData[currentName.value] = selectedOptions || []
visibleMultiple.value = false
isMultiple.value = false
}
// 打开关闭表单
const handleOpenClose = () => {
isOpen.value = !isOpen.value
}
// 级联选择器选择后(单选模式)
const onChange = (_: string | string[], selectedOptions: any[]) => {
if (!isMultiple.value) {
visible.value = false
// 单选模式value 是 stringselectedOptions 是选中的选项数组
formData[currentName.value] = selectedOptions?.map(item => item.value) || []
formSourceData[currentName.value] = selectedOptions || []
}
}
// 点击后打开级联选择器
const handleSelect = (item) => {
// 如果禁用,则不打开级联选择器
if (props.disabled) {
return
}
// 当前选择项的名称
currentName.value = item.name
if (item.inputType === InputTypeEnum.MultipleSelect) {
visibleMultiple.value = true
} else {
// 显示级联选择器
visible.value = true
}
// 是否多选
isMultiple.value = item.inputType === InputTypeEnum.MultipleSelect
// 下拉选择项配置
options.value = item.selectConfig?.options || []
// 已选择项的源数据中获取当前项的值
const currentList = formSourceData[item.name] || []
if (isMultiple.value) {
// 多选模式:初始化多选值
if (currentList.length === 0) {
cascaderMultipleValue.value = []
} else {
cascaderMultipleValue.value = currentList.map((item: any) => item.value || '')
}
} else {
// 单选模式:初始化单选值
if (currentList.length === 0) {
cascaderValue.value = ''
} else {
const lastItem = currentList[currentList.length - 1]
cascaderValue.value = lastItem.value || ''
}
}
}
// 取消级联选择
const handleCancelSelect = () => {
// 当前选择项的名称
currentName.value = ''
// 隐藏级联选择器
visible.value = false
visibleMultiple.value = false
isMultiple.value = false
// 下拉选择项配置
options.value = [];
// 已选择项的值
cascaderValue.value = '';
cascaderMultipleValue.value = [];
}
// 输入框输入值
const handleChangeInput = (event: UniInputChangeEvent, item) => {
formData[item.name] = event.detail.value
formSourceData[item.name] = event.detail.value
}
</script>
<style lang="scss" scoped>
.variables-box {
max-height: 50%;
border-radius: 10rpx;
background-color: #fff;
border: 1rpx solid #e5e5e5;
box-sizing: border-box;
margin: 0 32rpx;
.header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
font-weight: bold;
padding: 18rpx 20rpx;
.title {
font-size: 28rpx;
color: #15171F;
}
}
.down-icon {
color: #95979c;
user-select: none;
font-size: 32rpx;
transition: transform 0.3s ease-in-out;
&.rotate-90 {
transform: rotate(-90deg);
}
}
.conversation-container {
flex: 1;
display: flex;
flex-direction: column;
padding-top: 20rpx;
border-top: 1rpx solid #e5e5e5;
box-sizing: border-box;
overflow: hidden;
}
.close-form {
transition: height 0.3s ease-in-out;
height: 0;
overflow: hidden;
}
.form-box {
flex: 1;
max-height: 400rpx;
overflow-y: auto;
padding: 0 20rpx;
.uni-form-item {
padding: 16rpx 0;
.form-item-title {
display: flex;
flex-direction: row;
align-items: center;
.display-name {
font-size: 28rpx;
color: #15171F;
}
.required {
margin-left: 4rpx;
color: #FF0000;
}
}
.uni-input {
width: 100%;
height: 72rpx;
line-height: 40rpx;
background-color: rgba(12,20,40,0.04);
border-radius: 8rpx;
font-size: 28rpx;
color: #15171F;
padding: 16rpx;
margin-top: 8rpx;
box-sizing: border-box;
}
// 禁用样式
.disabled-style {
background-color: #f5f5f5;
color: #999;
}
.uni-placeholder {
color: #6b6b75;
font-size: 28rpx;
}
.multiple-box {
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.06);
padding: 4rpx 12rpx;
margin-right: 16rpx;
border-radius: 8rpx;
.disabled-style {
background-color: transparent;
}
}
}
}
.desc-box {
display: flex;
justify-content: center;
height: 70rpx;
padding: 0 20rpx;
box-sizing: border-box;
.desc {
color: #e5e5e5;
font-size: 28rpx;
}
}
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<view class="related-conversation">
<view class="header">
<text class="title">{{ t("Mobile.Conversation.relatedTitle") }}</text>
<view class="view-all" @click="onViewAll(chatList)">
<text class="view-all-text">{{
t("Mobile.Conversation.viewAll")
}}</text>
<text class="iconfont icon-a-Chevronright" />
</view>
</view>
<custom-nav-bar-speak :chat-list="chatList?.slice(0, 10)" />
</view>
</template>
<script setup lang="uts">
import type { ConversationInfo } from "@/types/interfaces/conversationInfo";
import type { AgentDetailDto } from "@/types/interfaces/agent";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import { apiAgentConversationList } from "@/servers/conversation";
import CustomNavBarSpeak from "@/components/custom-nav-bar/custom-nav-bar-speak/custom-nav-bar-speak.uvue";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
interface Props {
agentInfo: AgentDetailDto;
}
// 接收组件属性,设置默认值
const props = withDefaults(defineProps<Props>(), {});
// 定义自定义事件
const emit = defineEmits<{
// 查看全部事件
"view-all": [type: string];
}>();
const chatList = ref<ConversationInfo[]>([]);
// 查看全部处理
const onViewAll = (data: ConversationInfo[]) => {
emit("view-all", data);
};
// 查询用户历史会话
const fetchUserConversationList = async () => {
const { code, data } = await apiAgentConversationList({
agentId: props.agentInfo.agentId,
});
if (code === SUCCESS_CODE) {
chatList.value = data || [];
}
};
onMounted(() => {
fetchUserConversationList();
});
</script>
<style lang="scss" scoped>
.related-conversation {
padding: 0rpx;
background-color: #fff;
border-radius: 16rpx;
margin-bottom: 24rpx;
.header {
margin-top: 20rpx;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 20rpx 10rpx;
.title {
font-size: 28rpx;
font-weight: 600;
color: rgba(21, 23, 31, 1);
line-height: 44rpx;
}
.view-all {
display: flex;
flex-direction: row;
align-items: center;
.view-all-text {
font-size: 24rpx;
color: rgba(130, 136, 148, 1);
margin-right: 5rpx;
line-height: 38rpx;
}
.iconfont {
font-size: 26rpx;
color: #898a8e;
}
}
}
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<modal-popup ref="modal" :title="title" :enable-swipe-close="false">
<template #header-extra>
<view class="copy-btn" @tap="copy">
<text class="iconfont icon-CopyOutlined"></text>
</view>
</template>
<view class="details-content">
<mp-html :content="formattedDetailData" :markdown="true" :show-header="false"></mp-html>
</view>
</modal-popup>
</template>
<script lang="uts" setup>
import { ref, computed } from 'vue';
import { t } from '@/utils/i18n';
import modalPopup from "@/components/modal-popup/modal-popup.uvue";
import mpHtml from '@/subpackages/uni_modules/mp-html/components/mp-html/mp-html.vue'
/**
* 工具详情弹窗组件
*/
defineOptions({
name: "ToolDetailsModal"
})
// 定义组件属性
const props = withDefaults(defineProps<{
title?: string,
detailData?: any
}>(), {
title: "",
detailData: null
})
// 引用子组件
const modal = ref<any>(null);
// 计算属性:格式化详情展示
const formattedDetailData = computed(() : string => {
const data = props.detailData;
if (data == null) {
return "";
}
let content = '';
try {
content = JSON.stringify(data, null, 2);
} catch (e) {
content = String(data);
}
return `\`\`\`json\n${content}\n\`\`\``;
})
/**
* 复制数据到剪贴板
*/
const copy = () => {
const data = props.detailData;
if (data == null) {
return;
}
let content = '';
try {
content = JSON.stringify(data, null, 2);
} catch (e) {
content = String(data);
}
uni.setClipboardData({
data: content,
success: () => {
uni.showToast({
title: t('Mobile.Common.copySuccess'),
icon: "none",
});
},
fail: () => {
uni.showToast({
title: t('Mobile.Common.copyFailed'),
icon: "none",
});
}
});
}
/**
* 打开弹窗
*/
const open = () => {
modal.value?.open();
}
/**
* 关闭弹窗
*/
const close = () => {
modal.value?.close();
}
// 暴露组件方法
defineExpose({
open,
close
})
</script>
<style lang="scss" scoped>
.copy-btn {
margin: 0 25rpx;
.iconfont {
color: #333;
font-size: 32rpx;
}
}
.details-content {
/* 可以在这里添加额外的样式 */
}
</style>

View File

@@ -0,0 +1,143 @@
import { ref } from "vue";
import type {
AgentDetailDto,
AgentManualComponentInfo,
GuidQuestionDto,
FileNode,
} from "@/types/interfaces/agent";
import { BindConfigWithSub } from "@/types/interfaces/common";
import {
MessageInfo,
ConversationInfo,
ProcessingInfo,
OpenAppParams,
} from "@/types/interfaces/conversationInfo";
import { SandboxInfo } from "@/types/interfaces/sandbox";
/**
* 数据层:负责状态管理和数据存储
*/
export default class AgentDetailData {
// 基础信息
agentId = ref<number | null>(null);
agentName = ref<string>("");
agentInfo = ref<AgentDetailDto | null>(null);
conversationId = ref<number | null>(null);
// 是否是临时会话
isTempChat = ref<boolean>(false);
// 首页页面URL
pageHomeUrl = ref<string>("");
// 首页页面弹窗
popupPage = ref<any>(null);
// 消息相关
messageList = ref<MessageInfo[]>([]);
// 引导问题建议
chatSuggestList = ref<string[] | GuidQuestionDto[]>([]);
requestId = ref<string>("");
messageIdRef = ref<string>("");
// 当前会话消息请求ID
currentConversationRequestId = ref<string>("");
// 停止操作是否正在进行中
isStoppingConversation = ref<boolean>(false);
// 会话状态
conversationInfo = ref<ConversationInfo | null>(null);
// 是否用户问题建议
isSuggest = ref<boolean>(false);
// 是否需要更新主题
needUpdateTopicRef = ref<boolean>(true);
// 是否正在加载会话
isLoadingConversation = ref<boolean>(false);
// 会话是否正在进行中(有消息正在处理)
isConversationActive = ref<boolean>(false);
// 是否发送过消息,如果是,则禁用变量参数
isSendMessageRef = ref<boolean>(false);
// 是否禁用发送按钮
wholeDisabled = ref<boolean>(false);
// ==================== 临时会话相关 ====================
// 链接Key
chatKey = ref<string>("");
// 验证码参数
captchaVerifyParam = ref<string>("");
// 会话唯一标识
// 会话唯一标识
conversationUid = ref<string>("");
// ==================== 沙盒相关 ====================
// 沙盒列表
sandboxList = ref<SandboxInfo[]>([]);
// 智能体选中的沙盒映射
agentSelectedSandbox = ref<{ [key: string]: string }>({});
// 当前选中的沙盒ID
currentSandboxId = ref<string>("");
// 是否允许切换沙盒 (当ID固定时为true)
isSandboxSwitchDisabled = ref<boolean>(false);
// 沙盒是否不可用 (当显示"电脑不可用"时为true)
isSandboxUnavailable = ref<boolean>(false);
// 沙盒不可用时的提示文案
sandboxDisabledText = ref<string>("");
// ==================== 模型选择相关 ====================
// 当前选中的模型ID
currentModelId = ref<number>(0);
// 当前选中的模型名称
currentModelName = ref<string>("");
// 模型选择弹窗是否可见
modelSelectVisible = ref<boolean>(false);
// 组件和功能
manualComponents = ref<AgentManualComponentInfo[]>([]);
// 已选组件列表
selectedComponents = ref<AgentSelectedComponentInfo[]>([]);
// 变量参数
variables = ref<BindConfigWithSub[]>([]);
// 用户填充变量
userFillVariables = ref<{ [key: string]: string | number }>({});
processingList = ref<ProcessingInfo[]>([]);
messageRefs = ref<{ [key: string]: any }>({});
// 滚动相关
scrollIntoView = ref<string>("");
scrollInBottom = ref<boolean>(true);
scrollWithAnimation = ref<boolean>(false);
scrollTouch = ref<boolean>(false);
scrolling = ref<boolean>(false);
keyboardHeight = ref<number>(0);
// 弹窗引用
refMorePopup = ref<any>(null);
refSubscriptionModal = ref<any>(null);
sseProcessor: any = null;
autoToLastMsg = ref<boolean>(false);
scrollingT = ref<number>(0);
mouseScroll = ref<boolean>(false);
needSetLockAutoToLastMsg = ref<boolean>(true);
// ==================== 任务型智能体文件列表相关 ====================
// 文件列表
fileList = ref<FileNode[]>([]);
// 是否正在加载文件列表
isLoadingFiles = ref<boolean>(false);
// ==================== 加载更多历史消息相关 ====================
// 是否正在加载更多消息
isLoadingMoreMessages = ref<boolean>(false);
// 是否还有更多历史消息
hasMoreMessages = ref<boolean>(false);
// ==================== 开放应用参数, 来自用户自定义url地址传入的参数params ====================
appParams = ref<OpenAppParams | null>(null);
// 如果没有携带message参数则将其他参数(files、attachments、selectedComponents、skillIds、modelId等)保存到urlOtherParams中用于后续首次发送消息时使用
urlOtherParams = ref<{ [key: string]: string }>({});
// ==================== 订阅和付费限制相关 ====================
// 是否需要付费/订阅锁定
subscriptionRequired = ref<boolean>(false);
// 付费/订阅弹窗可见性
subscriptionModalVisible = ref<boolean>(false);
// 是否允许开启订阅限制(根据租户配置中的 enableSubscription 是否等于 1 来决定)
enableSubscription = ref<number>(0);
}

View File

@@ -0,0 +1,93 @@
import {
MessageInfo,
ProcessingInfo,
} from "@/types/interfaces/conversationInfo";
import { BindConfigWithSub } from "@/types/interfaces/common";
import { MessageStatusEnum } from "@/types/enums/common";
import AgentDetailData from "./AgentDetailData.uts";
/**
* 工具层:提供通用的工具函数
*/
export default class AgentDetailUtils {
/**
* 根据datasList内容动态确定type类型
*/
static determineTypeFromData(dataList: any[][]): string {
if (!dataList || dataList.length === 0) return "text";
const firstToken = dataList[0]?.[0];
if (firstToken) {
switch (firstToken.type) {
case "code":
return "code";
case "table":
return "table";
case "list":
return "list";
case "heading":
return "heading";
case "blockquote":
return "quote";
case "hr":
return "divider";
case "math":
return "math";
case "paragraph":
return "paragraph";
default:
return "text";
}
}
return "text";
}
/**
* 生成唯一的uniqueId
*/
static generateUniqueId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 15);
return `markdown_${timestamp}_${random}`;
}
/**
* 检查会话是否正在进行中
*/
static checkConversationActive(
messages: MessageInfo[],
data: AgentDetailData,
): void {
const hasActiveMessage =
(messages?.length &&
messages.some(
(message) =>
message.status === MessageStatusEnum.Loading ||
message.status === MessageStatusEnum.Incomplete,
)) ||
false;
data.isConversationActive.value = hasActiveMessage;
}
/**
* 处理处理中的消息列表
*/
// static handleChatProcessingList(_processingList: ProcessingInfo[], data: AgentDetailData): void {
// const processedMap = new Map<string, ProcessingInfo>()
// _processingList.forEach((item) => {
// const key = item.executeId || ''
// const existing = processedMap.get(key)
// if (!existing) {
// processedMap.set(key, item)
// }
// })
// const newProcessingList: ProcessingInfo[] = []
// processedMap.forEach((value) => {
// newProcessingList.push(value)
// })
// data.processingList.value = newProcessingList
// }
}

View File

@@ -0,0 +1,109 @@
import { nextTick } from 'vue'
import { MessageInfo } from '@/types/interfaces/conversationInfo'
import { MessageTypeEnum } from '@/types/enums/agent'
import { MessageStatusEnum } from '@/types/enums/common'
import AgentDetailData from './AgentDetailData.uts'
import AgentDetailUtils from './AgentDetailUtils.uts'
/**
* 组件管理层:负责组件的引用管理和更新
*/
export default class ComponentManager {
private data: AgentDetailData
constructor(private data: AgentDetailData) {
this.data = data
}
/**
* 设置消息组件引用
*/
setMessageRef(el: any, messageId: string): void {
if (el) {
this.data.messageRefs.value[messageId] = el
}
}
/**
* 移除消息组件引用
*/
removeMessageRef(messageId: string): void {
if (this.data.messageRefs.value[messageId]) {
delete this.data.messageRefs.value[messageId]
}
}
/**
* 更新消息组件
*/
async updateMessageComponent(messageId: string, newData: any): Promise<void> {
const messageRef = this.data.messageRefs.value[messageId]
if (messageRef) {
try {
if (typeof messageRef.updateMessage === 'function') {
await messageRef.updateMessage(newData)
} else {
// 备用方案:强制触发组件更新
await nextTick()
const message = this.data.messageList.value.find(msg => msg.id === messageId)
if (message) {
message.lastUpdateTime = Date.now()
this.data.messageList.value = [...this.data.messageList.value]
}
}
} catch (error) {
console.error(`更新消息组件 ${messageId} 失败:`, error)
// 错误恢复:使用备用方案
try {
await nextTick()
const message = this.data.messageList.value.find(msg => msg.id === messageId)
if (message) {
message.lastUpdateTime = Date.now()
this.data.messageList.value = [...this.data.messageList.value]
}
} catch (backupError) {
console.error('备用方案也失败了:', backupError)
}
}
}
}
/**
* 根据状态枚举确定状态
*/
determineState(status: MessageStatusEnum): number {
switch (status) {
case MessageStatusEnum.Loading:
return 1
case MessageStatusEnum.Incomplete:
return 2
case MessageStatusEnum.Complete:
return 3
case MessageStatusEnum.Error:
return 4
default:
return 3
}
}
/**
* 将MessageInfo转换为MsgItem格式
*/
convertMessageInfoToMsgItem(messageInfo: MessageInfo): any {
return {
_id: messageInfo.id?.toString() || '',
from_uid: messageInfo.messageType === MessageTypeEnum.ASSISTANT ? 'uni-ai' : 'user',
// thinkContent: messageInfo.text || messageInfo.think || '',
// body: messageInfo.text || messageInfo.think || '',
thinkContent: messageInfo.think || '',
body: messageInfo.text || '',
create_time: new Date(messageInfo.time || Date.now()).getTime(),
state: this.determineState(messageInfo.status),
chat_id: 'chat-' + this.data.conversationId.value,
// markdownElList: [],
// markdownElList: messageInfo.markdownElList || [],
rendered: false,
// 传递processingList用于自定义组件渲染历史消息的工具调用等
processingList: messageInfo.processingList || []
} as any
}
}

View File

@@ -0,0 +1,180 @@
# Agent Detail 分层架构说明
## 📁 文件结构
```
pages/agent-detail/
├── agent-detail.uvue # 主组件文件 (包含控制器逻辑和视图层逻辑)
├── styles/ # 样式文件目录
│ └── agent-detail.scss # 页面样式文件
└── layers/ # 分层架构目录
├── README.md # 本说明文档
├── AgentDetailData.uts # 数据层
├── AgentDetailUtils.uts # 工具层
├── AgentDetailService.uts # 服务层
├── ComponentManager.uts # 组件管理层
└── ScrollManager.uts # 滚动管理层
```
## 🏗️ 架构层次
### 1. 数据层 (Data Layer) - `AgentDetailData.uts`
**职责**: 负责状态管理和数据存储
- 管理所有响应式数据 (`ref`)
- 存储组件引用和私有变量
- 提供数据的统一访问接口
**特点**:
- 纯数据类,不包含业务逻辑
- 所有数据都是响应式的
- 便于状态管理和调试
### 2. 工具层 (Utility Layer) - `AgentDetailUtils.uts`
**职责**: 提供通用的工具函数
- 类型判断和转换
- ID生成和数据处理
- 纯函数设计,无副作用
**特点**:
- 静态方法,无需实例化
- 易于测试和复用
- 与业务逻辑解耦
### 3. 服务层 (Service Layer) - `AgentDetailService.uts`
**职责**: 负责业务逻辑和API调用
- 处理API请求和响应
- 管理业务流程
- 协调数据层和外部服务
**特点**:
- 依赖注入数据层
- 包含复杂的业务逻辑
- 处理异步操作
### 4. 组件管理层 (Component Management Layer) - `ComponentManager.uts`
**职责**: 负责组件的引用管理和更新
- 管理子组件的引用
- 处理组件间的通信
- 提供组件更新接口
**特点**:
- 管理组件生命周期
- 处理组件间依赖关系
- 提供组件更新策略
### 5. 滚动管理层 (Scroll Management Layer) - `ScrollManager.uts`
**职责**: 专门处理滚动相关的逻辑
- 管理滚动状态
- 处理滚动事件
- 控制自动滚动行为
**特点**:
- 专注于滚动功能
- 与业务逻辑解耦
- 易于独立测试
### 6. 视图层 (View Layer) - `agent-detail.uvue`
**职责**: 负责用户交互和事件处理
- 处理用户操作
- 协调各层之间的交互
- 管理视图状态
**特点**:
- 直接在主组件中实现
- 处理用户交互逻辑
- 协调各层功能
### 7. 主控制器 (Main Controller) - `agent-detail.uvue`
**职责**: 协调各层之间的交互
- 初始化各层实例
- 管理依赖关系
- 设置监听器和生命周期
**特点**:
- 直接在主组件中实现
- 管理各层生命周期
- 便于查看和维护
## 🔄 数据流向
```
用户操作 → View Layer (主组件) → Service → Data
组件更新 ← ComponentManager ← Data
滚动控制 ← ScrollManager ← Data
```
## 💡 使用方式
### 在主组件中使用
```typescript
// 1. 导入各层
import AgentDetailData from './layers/AgentDetailData.uts'
import AgentDetailService from './layers/AgentDetailService.uts'
import ComponentManager from './layers/ComponentManager.uts'
import ScrollManager from './layers/ScrollManager.uts'
// 2. 创建各层实例
const data = new AgentDetailData()
const service = new AgentDetailService(data)
const componentManager = new ComponentManager(data)
const scrollManager = new ScrollManager(data)
// 3. 直接在组件中实现视图层方法
const handleMorePopup = (): void => {
if (data.refMorePopup.value) {
nextTick(() => {
data.refMorePopup.value?.open()
})
}
}
// 4. 使用各层功能
const messageList = data.messageList
await service.handleSendMessage('Hello')
componentManager.setMessageRef(el, 'msg-1')
scrollManager.scrollToLastMsg(true)
```
### 添加新功能
1. **新增数据**: 在 `AgentDetailData.uts` 中添加新的 `ref`
2. **新增工具**: 在 `AgentDetailUtils.uts` 中添加新的静态方法
3. **新增业务**: 在 `AgentDetailService.uts` 中添加新的方法
4. **新增组件管理**: 在 `ComponentManager.uts` 中添加新的组件管理逻辑
5. **新增滚动功能**: 在 `ScrollManager.uts` 中添加新的滚动控制
6. **新增交互**: 直接在 `agent-detail.uvue` 中添加新的事件处理方法
7. **新增控制器逻辑**: 直接在 `agent-detail.uvue` 中添加
## ✅ 架构优势
1. **职责分离**: 每个类都有明确的职责边界
2. **依赖注入**: 通过构造函数注入依赖,降低耦合
3. **易于测试**: 每个层都可以独立进行单元测试
4. **代码复用**: 工具方法可以在其他地方复用
5. **维护性提升**: 修改某个功能时,只需要关注对应的层
6. **扩展性增强**: 新增功能时,可以轻松添加新的类或方法
7. **便于维护**: 控制器逻辑和视图层逻辑直接在主组件中,方便查看和调试
## 🧪 测试策略
- **数据层**: 测试数据初始化和响应式更新
- **工具层**: 测试工具函数的输入输出
- **服务层**: 测试API调用和业务逻辑
- **组件管理层**: 测试组件引用管理
- **滚动管理层**: 测试滚动状态和事件
- **视图层**: 测试用户交互处理(在主组件中)
- **主控制器**: 测试各层协调和初始化(在主组件中)
## 📝 注意事项
1. **依赖关系**: 确保各层之间的依赖关系清晰
2. **错误处理**: 在服务层和组件管理层添加适当的错误处理
3. **性能优化**: 避免在数据层中存储过多数据
4. **类型安全**: 使用TypeScript确保类型安全
5. **文档更新**: 添加新功能时及时更新文档
6. **控制器维护**: 控制器逻辑在主组件中,需要保持代码整洁
7. **视图层维护**: 视图层方法直接在主组件中,便于调试和维护
8. **样式维护**: 样式文件独立管理,便于主题定制和样式复用

View File

@@ -0,0 +1,121 @@
import { nextTick } from "vue";
import AgentDetailData from "./AgentDetailData.uts";
/**
* 滚动管理层:专门处理滚动相关的逻辑
* 兼容微信小程序和 H5/App
*/
export default class ScrollManager {
private data: AgentDetailData;
// 记录上次滚动位置,用于判断滚动方向
private lastScrollTop: number = 0;
constructor(private data: AgentDetailData) {
this.data = data;
}
/**
* 滚动到最后一条消息
* 使用 scroll-into-view 方式,兼容所有平台
*/
scrollToLastMsg(userClick: boolean): void {
this.data.autoToLastMsg.value = true;
this.data.scrollIntoView.value = "";
this.data.scrollWithAnimation.value = userClick;
nextTick(() => {
this.data.scrollIntoView.value = "last-msg";
this.data.scrollInBottom.value = true;
});
}
/**
* 设置是否在滚动底部
* 使用 scroll 事件参数,兼容微信小程序
*/
setIsInScrollBottom(e: UniScrollEvent): void {
nextTick(() => {
// 使用事件参数获取滚动信息,兼容微信小程序
const scrollTop = e?.detail?.scrollTop ?? 0;
const scrollHeight = e?.detail?.scrollHeight ?? 0;
// #ifdef MP-WEIXIN
// 微信小程序需要通过其他方式获取容器高度,这里使用估算值
const offsetHeight = 600; // 默认视窗高度估算
// #endif
// #ifndef MP-WEIXIN
const scrollList = uni.getElementById("msg-list");
const offsetHeight = scrollList?.offsetHeight ?? 600;
// #endif
const diff: number = scrollHeight - scrollTop - offsetHeight;
const isInBottom = diff < 50;
this.data.scrollInBottom.value = isInBottom;
// 如果滑到底部,自动恢复自动滚动
if (isInBottom) {
this.data.autoToLastMsg.value = true;
}
// 只有在用户主动滚动(触摸或鼠标)时才可能禁用自动滚动
// 且只在向上滚动(远离底部)时禁用
const isScrollingUp = scrollTop < this.lastScrollTop;
this.lastScrollTop = scrollTop;
if (
(this.data.scrollTouch.value || this.data.mouseScroll.value) &&
isScrollingUp && !isInBottom
) {
// 用户向上滚动时,立即禁用自动滚动,不需要等待滚动到一定距离
// 这样用户可以更容易地中断快速消息流带来的自动滚动
this.data.autoToLastMsg.value = false;
// 同时显示"滚动到底部"按钮
this.data.scrollInBottom.value = false;
}
});
}
/**
* 锁定自动滚动到底部
* 修复:不再过早禁用自动滚动,仅在特殊情况下处理
*/
lockAutoToLastMsg(): void {
// 如果已经处理过或不需要锁定,直接返回
if (!this.data.needSetLockAutoToLastMsg.value) return;
// 简化逻辑:只有当消息列表为空时才禁用
// 其他情况保持 autoToLastMsg 的当前状态
if (this.data.messageList.value.length === 0) {
this.data.autoToLastMsg.value = false;
this.data.needSetLockAutoToLastMsg.value = false;
}
}
/**
* 滚动事件处理
* 修复:不再错误地设置 mouseScroll让触摸和鼠标滚动分开处理
*/
onScroll(e: UniScrollEvent): void {
// mouseScroll 只在 H5 平台由 wheel 事件设置,这里不设置
// 触摸滚动由 touchstart/touchend 事件控制 scrollTouch
this.setIsInScrollBottom(e);
this.data.scrolling.value = true;
if (this.data.scrollingT.value != 0) {
clearTimeout(this.data.scrollingT.value);
}
this.data.scrollingT.value = setTimeout(() => {
this.data.scrolling.value = false;
}, 500);
}
/**
* 滚动到指定消息位置
* 用于加载更多历史消息后保持滚动位置
*/
scrollToMessage(messageId: string | number): void {
this.data.scrollWithAnimation.value = false;
this.data.scrollIntoView.value = "";
// 延迟设置,确保微信小程序 DOM 渲染完成后再触发定位
setTimeout(() => {
this.data.scrollIntoView.value = `msg-wrapper-${messageId}`;
}, 150);
}
}

View File

@@ -0,0 +1,186 @@
/* Agent Detail 页面样式 */
.agent-detail-container {
display: flex;
// padding-top: env(safe-area-inset-top);
.right-buttons {
display: flex;
flex-direction: row;
.icon-box {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12rpx;
&-hover {
background-color: rgba(12, 20, 102, 0.04);
}
}
}
.content {
flex: 1;
padding: 32rpx;
overflow-y: auto;
.title-section {
margin-bottom: 40rpx;
}
}
.chat-container {
flex: 1;
.scroll-view-box {
flex: 1;
position: relative;
.msg-list {
flex: 1;
.user-msg {
font-size: 16px;
margin-top: 16rpx;
margin-left: auto;
margin-right: 32rpx;
max-width: 600rpx;
padding: 32rpx;
border-radius: 24rpx 0 24rpx 24rpx;
background: rgba(12, 20, 102, 0.12);
color: #15171f;
// font-size: 28rpx;
font-weight: 400;
line-height: 48rpx;
word-break: break-all;
overflow-wrap: anywhere;
/* #ifdef WEB || MP-WEIXIN */
cursor: text;
user-select: text;
-webkit-user-select: text;
white-space: pre-wrap;
/* #endif */
}
.msg-list-content {
display: flex;
flex-direction: column;
gap: 48rpx;
padding: 16rpx 0;
.files-container {
width: 100%;
margin-left: auto;
padding: 0 32rpx;
box-sizing: border-box;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
height: auto;
min-height: 128rpx;
.files-list {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: flex-end;
gap: 10rpx;
margin-left: auto;
}
.files-box {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
// margin-right: 16rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
&-plus {
width: 128rpx;
height: 128rpx;
background: #f6f6f6;
}
&-image {
width: 128rpx;
height: 128rpx;
background: #f6f6f6;
}
&-file {
width: 400rpx;
padding: 16rpx;
background: rgba(12, 20, 102, 0.04);
gap: 8rpx;
overflow: hidden;
.doc-image {
display: flex;
align-items: center;
justify-content: center;
width: 96rpx;
height: 96rpx;
}
.doc-info-container {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
font-weight: 400;
.doc-name {
font-size: 28rpx;
color: #15171f;
}
.doc-size {
font-size: 24rpx;
color: #828894;
}
}
}
.image {
width: 100%;
height: 100%;
}
}
}
}
}
.scroll-to-bottom {
position: absolute;
bottom: 10px;
right: 10px;
width: 35px;
height: 35px;
justify-content: center;
align-items: center;
background-color: #fff;
border-radius: 50px;
padding: 5px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.3);
z-index: 10;
/* #ifdef APP-HARMONY */
border: 1px solid #efefef;
/* #endif */
.iconfont {
font-size: 40rpx;
color: #555;
}
}
}
}
}