chore: initialize qiming workspace repository
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user