chore: initialize qiming workspace repository
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 是 string,selectedOptions 是选中的选项数组
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
// }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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. **样式维护**: 样式文件独立管理,便于主题定制和样式复用
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user