chore: initialize qiming workspace repository

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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