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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,355 @@
<template>
<view class="manual-wrapper">
<scroll-view
class="manual-container"
direction="horizontal"
:scroll-with-animation="true"
:show-scrollbar="false"
>
<!-- 页面首页 -->
<view
v-if="pageHomeIndex && expandPageArea === ExpandPageAreaEnum.Yes"
class="manual-box"
@click="handleOpenPagePreview"
>
<text class="icon iconfont"></text>
<text class="text">{{ t("Mobile.Common.pageHome") }}</text>
</view>
<!-- 工作台 -->
<view v-if="isTaskAgent" class="manual-box" @click="handleOpenFileTree">
<text class="icon iconfont"></text>
<text class="text">{{ t("Mobile.Common.workspace") }}</text>
</view>
<!-- 沙盒选择 -->
<view v-if="isTaskAgent && !readonly" class="manual-group">
<!-- 自定义 Select 触发区 -->
<view
v-if="!isSandboxUnavailable && currentSandboxName"
class="manual-box manual-select"
@click="openSandboxDrawer"
>
<text class="text">{{ currentSandboxName }}</text>
<text class="icon iconfont icon-arrow-down"></text>
</view>
<!-- 不可用状态 -->
<view v-else class="manual-box manual-select disabled">
<text class="text">{{ sandboxDisabledText }}</text>
</view>
</view>
<!-- 模型选择 -->
<view
v-if="allowOtherModel && currentModelName"
class="manual-box manual-select"
@click="modelSelectVisible = true"
>
<text class="text">{{ currentModelName }}</text>
<text class="icon iconfont icon-arrow-down"></text>
</view>
<template v-for="item in manualComponents" :key="item.id">
<view
class="manual-box"
:class="{ active: isSelected(item) }"
@click="handleSelectComponent(item)"
>
<text
class="icon iconfont"
:class="getIconClass(item.name)"
v-if="getIconClass(item.name)"
></text>
<text class="text">{{ item.name }}</text>
</view>
</template>
</scroll-view>
</view>
<page-preview-iframe ref="pagePreviewIframeRef" />
<!-- 沙盒切换弹窗 -->
<sandbox-select-modal
:visible="sandboxVisible"
:sandbox-list="sandboxList"
:current-sandbox-id="currentSandboxId"
:is-sandbox-switch-disabled="isSandboxSwitchDisabled"
:readonly="readonly"
@on-close="handleSandboxClose"
@on-sandbox-change="handleSandboxChangeInside"
/>
<!-- 模型选择弹窗 -->
<model-select-modal
v-if="allowOtherModel"
:visible="modelSelectVisible"
:agent-id="agentId"
:agent-type="agentType"
:current-model-id="currentModelId"
:is-temp-chat="isTempChat"
@on-close="modelSelectVisible = false"
@on-model-change="handleModelChangeInside"
/>
</template>
<script lang="uts" setup>
import { ExpandPageAreaEnum } from "@/types/enums/agent.uts";
import { AgentManualComponentInfo } from "@/types/interfaces/agent.uts";
import { SandboxInfo } from "@/types/interfaces/sandbox";
import SandboxSelectModal from "../sandbox-select-modal/sandbox-select-modal.uvue";
import { useI18n } from "@/utils/i18n";
import ModelSelectModal from "../model-select-modal/model-select-modal.uvue";
import { AgentTypeEnum } from "@/types/enums/agent.uts";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
manualComponents: AgentManualComponentInfo[];
selectedComponents: AgentSelectedComponentInfo[];
isTaskAgent?: boolean;
pageHomeIndex?: string;
expandPageArea?: number;
sandboxList?: SandboxInfo[];
currentSandboxId?: string;
isSandboxSwitchDisabled?: boolean;
isSandboxUnavailable?: boolean;
sandboxDisabledText?: string;
readonly?: boolean;
// 模型选择相关
allowOtherModel?: boolean;
currentModelId?: number;
currentModelName?: string;
agentId?: number;
agentType?: AgentTypeEnum;
isTempChat?: boolean;
}>(),
{
manualComponents: () => [],
selectedComponents: () => [],
isTaskAgent: false,
pageHomeIndex: "",
expandPageArea: 0,
sandboxList: () => [],
currentSandboxId: "",
isSandboxSwitchDisabled: false,
isSandboxUnavailable: false,
sandboxDisabledText: "",
readonly: false,
allowOtherModel: false,
currentModelId: 0,
currentModelName: "",
agentId: 0,
agentType: AgentTypeEnum.ChatBot,
isTempChat: false,
},
);
const emit = defineEmits<{
onSandboxChange: (sandboxId: string) => void;
onModelChange: (modelId: number, name: string) => void;
onSelectComponent: (item: AgentSelectedComponentInfo) => void;
onOpenPagePreview: (uri: string) => void;
onOpenFileTree: () => void;
}>();
// 当前显示的沙盒名称
const currentSandboxName = computed(() => {
const item = props.sandboxList.find(
(item) => item.sandboxId === props.currentSandboxId,
);
return item ? item.name : props.sandboxList[0]?.name || "";
});
// 当前显示的模型名称使用 props 传入的值
const modelSelectVisible = ref(false);
const handleModelChangeInside = (id: number, name: string) => {
emit("onModelChange", id, name);
modelSelectVisible.value = false;
};
// 页面预览iframe引用
const pagePreviewIframeRef = ref<any>(null);
const sandboxVisible = ref(false);
// 打开沙盒切换抽屉
const openSandboxDrawer = () => {
sandboxVisible.value = true;
};
// 抽屉内切换沙盒反馈
const handleSandboxChangeInside = (id: string) => {
emit("onSandboxChange", id);
sandboxVisible.value = false;
};
const handleSandboxClose = () => {
sandboxVisible.value = false;
};
// 点击工作台,打开文件树弹窗
const handleOpenFileTree = () => {
emit("onOpenFileTree");
};
// 打开页面预览
const handleOpenPagePreview = () => {
if (!props.pageHomeIndex) return;
// #ifdef H5 || WEB
let uri =
process.env.NODE_ENV === "production"
? `${window.location.origin}${props.pageHomeIndex}`
: props.pageHomeIndex;
// #endif
// #ifdef MP-WEIXIN
let uri = props.pageHomeIndex;
// #endif
emit("onOpenPagePreview", uri);
};
// 选择组件
const handleSelectComponent = (item: AgentManualComponentInfo) => {
const selectedComponent: AgentSelectedComponentInfo = {
id: item.id,
type: item.type,
};
emit("onSelectComponent", selectedComponent);
};
// 判断组件是否被选中
const isSelected = (item: AgentManualComponentInfo): boolean => {
return props.selectedComponents.some((selected) => selected.id === item.id);
};
// 根据组件名称返回对应的图标类名
const getIconClass = (name: string): string => {
const lowerName = (name || "").toLowerCase();
if (
name.includes("搜索") ||
name.includes("互联网") ||
lowerName.includes("search") ||
lowerName.includes("internet")
) {
return "icon-Globe";
} else if (
name.includes("研究") ||
name.includes("深度") ||
lowerName.includes("research") ||
lowerName.includes("deep")
) {
return "icon-bulb";
}
return "";
};
</script>
<style lang="scss" scoped>
.manual-wrapper {
display: flex;
flex-direction: row;
width: 100%;
height: 76rpx;
.manual-container {
flex: 1;
display: flex;
flex-direction: row;
height: 76rpx;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
.manual-group {
display: flex;
flex-direction: row;
}
.manual-box {
gap: 6rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 76rpx;
padding: 16rpx 24rpx;
margin-right: 16rpx;
border-radius: 16rpx;
border: 2rpx solid #f0f0f0;
transition: all 0.3s ease;
&.active {
background: #f2f2ff;
border-color: #f2f2ff;
.icon {
color: #4f46e5;
}
.text {
color: #5147ff;
}
}
.icon {
font-size: 32rpx;
color: #6b7280;
transition: color 0.3s ease;
}
.text {
color: rgba(21, 23, 31, 0.7);
font-size: 28rpx;
font-weight: 400;
white-space: nowrap;
}
&.manual-select {
border-color: #eeeeee;
min-width: 100rpx;
max-width: 300rpx;
flex-shrink: 0;
transition: all 0.3s ease;
display: flex;
flex-direction: row;
align-items: center;
.text {
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.icon-arrow-down {
font-size: 24rpx;
margin-left: -4rpx;
color: rgba(21, 23, 31, 0.4);
}
&.disabled {
opacity: 0.6;
background: #f5f5f5;
pointer-events: auto;
.text {
color: rgba(21, 23, 31, 0.7);
}
}
}
&.disabled {
opacity: 0.6;
background: #f5f5f5;
pointer-events: none;
.text {
color: rgba(21, 23, 31, 0.7);
}
}
}
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<radio-list-drawer
:visible="visible"
:title="t('Mobile.Chat.modelSelect')"
:list="modelOptions"
:current-value="currentModelId.toString()"
:readonly="readonly"
:loading="isLoading"
@onClose="handleClose"
@onChange="handleModelChange"
/>
</template>
<script lang="uts" setup>
import { AgentModelOption } from "@/types/interfaces/agent";
import { AgentTypeEnum } from "@/types/enums/agent";
import { RadioListItem } from "@/types/interfaces/radio";
import RadioListDrawer from "@/components/radio-list-drawer/radio-list-drawer.uvue";
import { apiGetAgentModelOptions } from "@/servers/agentDev";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import { t } from "@/utils/i18n";
const props = withDefaults(
defineProps<{
visible: boolean;
agentId: number;
agentType: AgentTypeEnum;
currentModelId: number;
readonly?: boolean;
isTempChat?: boolean;
}>(),
{
visible: false,
readonly: false,
isTempChat: false,
},
);
const emit = defineEmits<{
onClose: () => void;
onModelChange: (modelId: number) => void;
}>();
const modelOptions = ref<RadioListItem[]>([]);
const isLoading = ref(false);
// 获取并过滤模型列表
const fetchModelOptions = async () => {
if (props.isTempChat) return;
isLoading.value = true;
try {
modelOptions.value = [];
const res = await apiGetAgentModelOptions(props.agentId);
if (res.code === SUCCESS_CODE && res.data) {
const filteredModels = res.data.filter((model: AgentModelOption) => {
// 根据 usageScenarios 包含当前智能体类型来判断是否显示
return model.usageScenarios?.includes(props.agentType);
});
modelOptions.value = filteredModels.map((model: AgentModelOption) : RadioListItem => {
return {
label: model.name,
value: model.id.toString(),
desc: model.description || '',
disabled: false,
} as RadioListItem;
});
// 如果当前没有选中模型,则默认选中第一个
if (props.currentModelId === 0 && modelOptions.value.length > 0) {
handleModelChange(modelOptions.value[0].value);
}
}
} catch (error) {
console.error("[model-select-modal] 获取模型选项失败:", error);
} finally {
isLoading.value = false;
}
};
const handleClose = () => {
emit("onClose");
};
const handleModelChange = (value: string) => {
const item = modelOptions.value.find((i) => i.value === value);
emit("onModelChange", parseInt(value), item?.label || "");
};
watch(() => props.visible, (newVal) => {
if (newVal) {
fetchModelOptions();
}
});
// 初始化加载数据,以便在外部显示首项名称(即使弹窗未打开)
onMounted(() => {
fetchModelOptions();
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,66 @@
<template>
<radio-list-drawer
:visible="visible"
:title="t('Mobile.Sandbox.selectorTitle')"
:list="computedSandboxList"
:current-value="currentSandboxId"
:readonly="readonly"
@onClose="handleClose"
@onChange="handleSandboxChange"
/>
</template>
<script lang="uts" setup>
import { SandboxInfo } from "@/types/interfaces/sandbox";
import { useI18n } from "@/utils/i18n";
import { RadioListItem } from "@/types/interfaces/radio";
import RadioListDrawer from "@/components/radio-list-drawer/radio-list-drawer.uvue";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
visible: boolean;
sandboxList: SandboxInfo[];
currentSandboxId?: string;
isSandboxSwitchDisabled?: boolean;
readonly?: boolean;
}>(),
{
visible: false,
sandboxList: () => [] as SandboxInfo[],
currentSandboxId: "",
isSandboxSwitchDisabled: false,
readonly: false,
},
);
const emit = defineEmits<{
onClose: () => void;
onSandboxChange: (sandboxId: string) => void;
}>();
const computedSandboxList = computed(() => {
return props.sandboxList.map((item) : RadioListItem => {
const isDisabled = props.isSandboxSwitchDisabled && item.sandboxId !== props.currentSandboxId;
return {
label: item.name,
value: item.sandboxId,
desc: item.description || '',
disabled: isDisabled,
warningDesc: item.sandboxId !== '-1'
} as RadioListItem;
});
});
const handleClose = () => {
emit("onClose");
};
const handleSandboxChange = (value: string) => {
emit("onSandboxChange", value);
};
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,508 @@
<template>
<drawer-popup
ref="drawerPopupRef"
direction="bottom"
height="70vh"
:show-header="false"
@update-visible="handleUpdateVisible"
>
<view class="skill-select-container">
<!-- 头部 Tab 切换(带过渡动画的指示器) -->
<view class="skill-tabs">
<view
v-for="(tab, index) in tabs"
:key="index"
class="tab-item"
:class="{ active: currentTabIndex === index }"
@click="switchTabByIndex(index)"
>
<text class="tab-text">{{ tab.name }}</text>
</view>
<!-- 滑动指示器(带过渡动画) -->
<view
class="tab-indicator"
:style="{
width: indicatorWidth + 'px',
left: indicatorLeft + 'px',
}"
/>
</view>
<!-- 搜索框 -->
<view class="search-bar">
<input
class="search-input"
v-model="searchKw"
:placeholder="t('Mobile.Skill.searchSkillName')"
@input="handleInput"
:confirm-type="'search'"
@confirm="() => fetchSkillsForTab(currentTab)"
/>
<text
v-if="searchKw"
class="iconfont icon-cross clear-icon"
@click="clearSearch"
/>
</view>
<!-- 技能列表swiper 实现滑动过渡动画) -->
<swiper
class="skill-swiper"
:current="currentTabIndex"
@change="handleSwiperChange"
:duration="300"
easing-function="easeOut"
>
<swiper-item
v-for="(tab, index) in tabs"
:key="tab.value"
class="swiper-item"
>
<scroll-view
class="skill-list"
scroll-y="true"
:refresher-enabled="tab.value === 'all'"
:refresher-triggered="refresherTriggered"
@refresherrefresh="onRefresh"
@scrolltolower="onLoadMore"
>
<view
v-if="loading && (listDataByTab[tab.value] || []).length === 0"
class="loading-state"
>
<text class="loading-text">{{
t("Mobile.Page.loadingMore")
}}</text>
</view>
<view
v-else-if="
!loading && (listDataByTab[tab.value] || []).length === 0
"
class="empty-state"
>
<text class="empty-text">{{ t("Mobile.Skill.empty") }}</text>
</view>
<template v-else>
<view
v-for="item in listDataByTab[tab.value] || []"
:key="item.id"
class="skill-item"
@click="selectSkill(item)"
>
<image
class="skill-icon"
:src="item.icon || '/static/logo.png'"
mode="aspectFill"
:alt="item.name || t('Mobile.Common.skillIconAlt')"
/>
<view class="skill-info">
<text class="skill-name">{{ item.name }}</text>
<text class="skill-desc">{{ item.description }}</text>
</view>
</view>
</template>
<uni-load-more
v-if="
tab.value === 'all' &&
(listDataByTab[tab.value] || []).length > 0
"
:status="loadMoreStatus"
:content-text="loadMoreText"
/>
</scroll-view>
</swiper-item>
</swiper>
</view>
</drawer-popup>
</template>
<script lang="uts" setup>
import {
apiSkillListForAt,
apiSkillRecentlyUsedList,
apiSkillCollectList,
} from "@/subpackages/servers/skill";
import {
SkillInfoForAt,
SkillListForAtParams,
} from "@/types/interfaces/skill";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import { useI18n } from "@/utils/i18n";
import { getUniLoadMoreContentText } from "@/utils/i18n-third-party";
// 类型定义
import { AgentTypeEnum } from "@/types/enums/agent";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
visible: boolean;
}>(),
{
visible: false,
},
);
const emit = defineEmits<{
onClose: () => void;
onSelect: (skill: SkillInfoForAt) => void;
}>();
const drawerPopupRef = ref<any>(null);
const handleUpdateVisible = (val: boolean) => {
if (!val) {
handleClose();
}
};
const tabs = computed(() => [
{ name: t("Mobile.Common.all"), value: "all" },
{ name: t("Mobile.Skill.recentUsed"), value: "recent" },
{ name: t("Mobile.Skill.myFavorites"), value: "collect" },
]);
// 当前 Tab 索引(用于 swiper 和指示器)
const currentTabIndex = ref<number>(0);
const searchKw = ref<string>("");
// 按 Tab 分别存储列表数据,支持滑动过渡时展示各自内容
const listDataByTab = ref<Record<string, SkillInfoForAt[]>>({
all: [],
recent: [],
collect: [],
});
const loading = ref<boolean>(false);
// 分页及刷新状态仅「全部」Tab 使用)
const page = ref<number>(1);
const hasMore = ref<boolean>(true);
const refresherTriggered = ref<boolean>(false);
const loadMoreStatus = ref<string>("more");
const loadMoreText = computed(() => getUniLoadMoreContentText());
// Tab 指示器(带过渡动画)
const indicatorWidth = ref<number>(0);
const indicatorLeft = ref<number>(0);
// 防抖定时器
let searchTimer: any = null;
const currentTab = computed(
() => tabs.value[currentTabIndex.value]?.value ?? "all",
);
const handleInput = () => {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
fetchSkillsForTab(currentTab.value);
}, 300);
};
const clearSearch = () => {
searchKw.value = "";
fetchSkillsForTab(currentTab.value);
};
/** 更新 Tab 指示器位置(带过渡动画) */
const updateIndicator = () => {
nextTick(() => {
const systemInfo = uni.getSystemInfoSync();
const screenWidth = systemInfo.windowWidth || 375;
// 与 skill-tabs 的 padding: 0 40rpx 对应(设计稿 750rpx
const leftPadding = (screenWidth / 750) * 40;
const totalPadding = leftPadding * 2;
const tabsWidth = screenWidth - totalPadding;
const tabWidth = tabsWidth / tabs.value.length;
indicatorWidth.value = tabWidth;
indicatorLeft.value = leftPadding + currentTabIndex.value * tabWidth;
});
};
/** 点击 Tab 切换 */
const switchTabByIndex = (index: number) => {
if (currentTabIndex.value === index) return;
currentTabIndex.value = index;
updateIndicator();
fetchSkillsForTab(tabs.value[index].value);
};
/** Swiper 滑动切换 */
const handleSwiperChange = (e: any) => {
const index = e.detail?.current ?? 0;
if (currentTabIndex.value === index) return;
currentTabIndex.value = index;
updateIndicator();
fetchSkillsForTab(tabs.value[index].value);
};
const handleClose = () => {
searchKw.value = "";
// 关闭时清空数据,下次打开时重新拉取
listDataByTab.value = { all: [], recent: [], collect: [] };
emit("onClose");
};
const selectSkill = (skill: SkillInfoForAt) => {
emit("onSelect", skill);
handleClose();
};
/** 获取指定 Tab 的技能列表 */
const fetchSkillsForTab = async (
tab: string,
isLoadMore: boolean = false,
) => {
if (isLoadMore && loading.value) return;
if (!isLoadMore) {
page.value = 1;
hasMore.value = true;
loadMoreStatus.value = "more";
listDataByTab.value = { ...listDataByTab.value, [tab]: [] };
} else {
page.value++;
loadMoreStatus.value = "loading";
}
loading.value = true;
const fetchTab = tab;
try {
const kw = searchKw.value.trim();
if (tab === "all") {
const params: SkillListForAtParams = {
targetType: "Skill",
page: page.value,
pageSize: 20,
usageScenarios: [AgentTypeEnum.TaskAgent],
};
if (kw) {
params.kw = kw;
}
const res = await apiSkillListForAt(params);
if (currentTab.value !== fetchTab) return;
if (res.code === SUCCESS_CODE && res.data?.records) {
const records = res.data.records;
const prev = listDataByTab.value[tab] || [];
const newList = isLoadMore ? [...prev, ...records] : records;
listDataByTab.value = { ...listDataByTab.value, [tab]: newList };
hasMore.value = newList.length < res.data.total;
loadMoreStatus.value = hasMore.value ? "more" : "no-more";
} else if (!isLoadMore) {
listDataByTab.value = { ...listDataByTab.value, [tab]: [] };
loadMoreStatus.value = "no-more";
}
} else {
const params = {
targetType: "Skill",
kw: kw,
usageScenarios: [AgentTypeEnum.TaskAgent],
};
const res =
tab === "recent"
? await apiSkillRecentlyUsedList(params)
: await apiSkillCollectList(params);
if (currentTab.value !== fetchTab) return;
if (res.code === SUCCESS_CODE && res.data) {
listDataByTab.value = { ...listDataByTab.value, [tab]: res.data };
} else {
listDataByTab.value = { ...listDataByTab.value, [tab]: [] };
}
}
} catch (e) {
console.error("Fetch skills failed:", e);
if (!isLoadMore) {
listDataByTab.value = { ...listDataByTab.value, [tab]: [] };
}
} finally {
if (currentTab.value === fetchTab) {
loading.value = false;
refresherTriggered.value = false;
}
}
};
// 下拉刷新
const onRefresh = () => {
refresherTriggered.value = true;
fetchSkillsForTab("all", false);
};
// 上滑加载更多
const onLoadMore = () => {
if (currentTab.value === "all" && hasMore.value && !loading.value) {
fetchSkillsForTab("all", true);
}
};
// 监听显示状态
watch(
() => props.visible,
(newVal) => {
if (newVal) {
currentTabIndex.value = 0;
searchKw.value = "";
updateIndicator();
fetchSkillsForTab("all");
drawerPopupRef.value?.open();
} else {
drawerPopupRef.value?.close();
}
},
);
// 初始化指示器
onMounted(() => {
setTimeout(() => updateIndicator(), 100);
});
</script>
<style lang="scss" scoped>
.skill-select-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 10rpx 0;
width: 100%;
/* Tabs样式带过渡动画的指示器 */
.skill-tabs {
position: relative;
display: flex;
flex-direction: row;
border-bottom: 2rpx solid #f0f0f0;
padding: 0 40rpx;
.tab-item {
flex: 1;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding-bottom: 20rpx;
&.active {
.tab-text {
color: #4f46e5;
font-weight: 500;
}
}
.tab-text {
font-size: 28rpx;
color: #6b7280;
transition: color 0.2s ease;
}
}
.tab-indicator {
position: absolute;
bottom: 0;
height: 4rpx;
background-color: #4f46e5;
border-radius: 2rpx;
transition:
left 0.3s ease,
width 0.3s ease;
}
}
/* Swiper 滑动区域 */
.skill-swiper {
flex: 1;
overflow: hidden;
.swiper-item {
height: 100%;
}
}
/* 搜索框 */
.search-bar {
margin: 20rpx 40rpx;
background-color: #f3f4f6;
border-radius: 16rpx;
padding: 16rpx 24rpx;
display: flex;
flex-direction: row;
align-items: center;
position: relative;
.search-input {
flex: 1;
font-size: 28rpx;
background-color: transparent;
}
.clear-icon {
font-size: 24rpx;
color: #9ca3af;
padding: 10rpx;
}
}
/* 列表视图 */
.skill-list {
flex: 1;
padding: 0 40rpx;
overflow: hidden;
.skill-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 12rpx 0;
border-bottom: 2rpx solid #f9fafb;
&:active {
background-color: #f9fafb;
}
.skill-icon {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
margin-right: 24rpx;
background-color: #f3f4f6;
}
.skill-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
.skill-name {
font-size: 28rpx;
color: #111827;
font-weight: 500;
}
.skill-desc {
font-size: 24rpx;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
/* 状态视图 */
.loading-state,
.empty-state {
padding-top: 100rpx;
align-items: center;
text-align: center;
.loading-text,
.empty-text {
font-size: 28rpx;
color: #9ca3af;
}
}
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<view class="skill-tags-wrapper" v-if="selectedSkills.length > 0">
<scroll-view class="skill-tags-scroll" :scroll-x="true" direction="horizontal" :show-scrollbar="false">
<view class="skill-tags-list">
<view v-for="(skill, index) in selectedSkills" :key="skill.id" class="skill-tag">
<text class="tag-text">{{ skill.name }}</text>
<text class="tag-close iconfont icon-X" @click="handleRemove(index)"></text>
</view>
<view class="skill-tag-add" @click="handleAdd">
<text class="iconfont icon-Plus"></text>
</view>
</view>
</scroll-view>
</view>
</template>
<script lang="uts" setup>
import { SkillInfoForAt } from '@/types/interfaces/skill';
defineProps<{
selectedSkills: SkillInfoForAt[]
}>();
const emit = defineEmits<{
(e: 'onRemove', index: number): void;
(e: 'onAdd'): void;
}>();
const handleRemove = (index: number) => {
emit('onRemove', index);
};
const handleAdd = () => {
emit('onAdd');
};
</script>
<style lang="scss" scoped>
.skill-tags-wrapper {
width: 100%;
padding-bottom: 10rpx;
border-bottom: 2rpx solid #f3f4f6;
.skill-tags-scroll {
width: 100%;
flex-direction: row;
.skill-tags-list {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
.skill-tag {
display: flex;
flex-direction: row;
align-items: center;
flex: none;
background-color: #f3f4f6;
border-radius: 8rpx;
padding: 6rpx 16rpx;
margin-right: 16rpx;
.tag-text {
font-size: 24rpx;
color: #4b5563;
margin-right: 8rpx;
max-width: 220rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-close {
font-size: 24rpx;
color: #9ca3af;
padding: 4rpx;
}
}
.skill-tag-add {
display: flex;
align-items: center;
justify-content: center;
flex: none;
background-color: #f3f4f6;
border-radius: 8rpx;
width: 43rpx;
height: 43rpx;
.icon-Plus {
font-size: 24rpx;
color: #6b7280;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<view class="file-preview-h5" :style="containerStyle">
<!-- Loading Overlay -->
<view v-show="loading" class="state-overlay">
<!-- <text>加载中...</text> -->
</view>
<!-- Error Overlay -->
<view v-show="error" class="state-overlay error">
<text class="error-text">{{ error }}</text>
<button size="mini" @click="reload">
{{ i18n("Mobile.Common.retry") }}
</button>
</view>
<!-- Preview iframe -->
<iframe
v-if="previewUrl"
ref="previewIframe"
:src="previewUrl"
class="preview-iframe"
frameborder="0"
allowfullscreen
@load="onIframeLoad"
@error="onIframeError"
></iframe>
</view>
</template>
<script>
import { API_BASE_URL } from '@/constants/config';
import { translateText } from '@/utils/i18n';
export default {
name: 'FilePreviewH5',
props: {
src: {
type: String,
required: true
},
fileType: {
type: String,
default: ''
},
height: {
type: String,
default: '100%'
}
},
computed: {
containerStyle() {
return {
minHeight: this.height,
height: this.height
};
},
previewUrl() {
if (!this.src) return '';
// Get the base URL for the preview page
// In production, H5 is served under /m/ path
const baseUrl = this.getPreviewBaseUrl();
// Build query parameters
const params = new URLSearchParams();
params.set('fileUrl', this.src);
if (this.fileType) {
params.set('fileType', this.fileType);
}
// Use /static/ for H5 deployment
return `${baseUrl}/static/file-preview.html?${params.toString()}`;
}
},
data() {
return {
loading: true,
error: null
};
},
watch: {
src: {
handler(newSrc) {
if (newSrc) {
this.loading = true;
this.error = null;
}
},
immediate: true
}
},
mounted() {
console.log('[FilePreviewH5] Mounted with iframe mode', { src: this.src, type: this.fileType });
// Listen for messages from iframe
window.addEventListener('message', this.handleMessage);
},
beforeUnmount() {
window.removeEventListener('message', this.handleMessage);
},
methods: {
i18n(key, fallback = '') {
const value = translateText(key || '');
return value || fallback || key;
},
getPreviewBaseUrl() {
// 判断是否为开发环境
if (process.env.NODE_ENV === 'development') {
console.log('API_BASE_URL',API_BASE_URL);
return API_BASE_URL;
}
// In development, use current origin
// In production, use the same origin or configured base URL
if (typeof window !== 'undefined') {
return window.location.origin;
}
return '';
},
onIframeLoad() {
console.log('[FilePreviewH5] Iframe loaded');
// Don't hide loading here - wait for preview_success message
},
onIframeError(e) {
console.error('[FilePreviewH5] Iframe error:', e);
this.loading = false;
this.error = this.i18n('Mobile.FilePreview.previewPageLoadFailed');
},
handleMessage(event) {
// Validate message origin if needed
const data = event.data;
if (!data || !data.type) return;
console.log('[FilePreviewH5] Received message:', data);
if (data.type === 'preview_success') {
this.loading = false;
this.error = null;
this.$emit('load');
} else if (data.type === 'preview_error') {
this.loading = false;
this.error = data.error || this.i18n('Mobile.FilePreview.documentRenderFailed');
this.$emit('error', data.error);
}
},
reload() {
this.loading = true;
this.error = null;
// Force iframe reload by toggling src
const iframe = this.$refs.previewIframe;
if (iframe) {
const currentSrc = iframe.src;
iframe.src = '';
this.$nextTick(() => {
iframe.src = currentSrc;
});
}
}
}
};
</script>
<style scoped>
.file-preview-h5 {
width: 100%;
height: 100%;
min-height: 400px;
background: #fff;
position: relative;
}
.state-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
z-index: 10;
color: #666;
}
.state-overlay.error {
color: #ff4d4f;
}
.error-text {
margin-bottom: 16px;
}
.preview-iframe {
width: 100%;
height: 100%;
border: none;
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<!-- AI校验通过后需要水平滚动表格时需要设置 scroll-table="true" -->
<mp-html
:content="processedText"
:markdown="true"
:container-style="`width: 100%;`"
:scroll-table="true"
@linktap="handleLinkTap"
/>
</template>
<script lang="uts" setup>
import { replaceMathBracket, groupMarkdownContainers } from '@/utils/markdown.uts'
import mpHtml from '@/subpackages/uni_modules/mp-html/components/mp-html/mp-html.vue'
import { handleExternalLink } from '@/utils/system.uts'
// 定义组件属性
const props = withDefaults(defineProps<{
text?: string
}>(), {
text: ''
})
// 处理文本内容,应用数学公式格式转换
const processedText = computed(() => {
if (!props.text || props.text.trim().length === 0) {
return ''
}
// 先处理分组,再处理数学公式
const groupedText = groupMarkdownContainers(props.text)
console.log('groupedText', groupedText)
return replaceMathBracket(groupedText)
})
// 处理链接点击事件
const handleLinkTap = (event: any) => {
const { href } = event.detail
if (href && href.length > 0) {
handleExternalLink(href);
}
}
</script>

View File

@@ -0,0 +1,14 @@
/**
* 事件常量
* 用于事件订阅和发布时的类型安全引用
*
* 与 PC 端 @/constants/event.constants.tsx 保持一致
*/
import { EventTypeEnum } from '@/types/enums/event';
export const EVENT_TYPE = {
NewNotifyMessage: EventTypeEnum.NewNotifyMessage, // 新消息
RefreshChatMessage: EventTypeEnum.RefreshChatMessage, // 刷新会话
RefreshFileList: EventTypeEnum.RefreshFileList, // 刷新文件列表
ChatFinished: EventTypeEnum.ChatFinished, // 会话结束
};

View File

@@ -0,0 +1,203 @@
import { ref, onMounted, onUnmounted, Ref } from 'vue'
import { EventBindResponseActionEnum } from '@/types/enums/agent'
import { checkPathParams, fillPathParams, objectToQueryString } from '@/utils/common.uts'
import { EventBindConfig, ShowPagePreviewOptions } from '@/types/interfaces/agent'
import { API_BASE_URL } from '@/constants/config'
import { handleExternalLink } from '@/utils/system.uts'
import { t } from '@/utils/i18n'
// ============ 工具方法 ============
const showError = (msg: string): void => {
uni.showToast({
title: msg,
icon: 'none'
})
}
// ============ 防重复触发管理器 ============
/**
* 事件防重复触发管理器
* 用于防止短时间内重复触发相同的事件
*/
export class EventDedupManager {
private handledEvents: Set<string> = new Set()
private timeout: number = 1000
constructor(timeout?: number) {
if (timeout !== undefined) {
this.timeout = timeout
}
}
/**
* 检查事件是否已经被处理
* @param eventKey 事件键
* @returns 是否已处理true 表示已处理,应该跳过)
*/
isDuplicate(eventKey: string): boolean {
if (this.handledEvents.has(eventKey)) {
return true
}
this.handledEvents.add(eventKey)
setTimeout(() => {
this.handledEvents.delete(eventKey)
}, this.timeout)
return false
}
/**
* 清除所有记录
*/
clear(): void {
this.handledEvents.clear()
}
}
// ============ 导出的事件处理函数 ============
/**
* 处理消息事件点击
* 可以在任何地方独立调用此函数来处理事件
*
* @param eventType 事件类型标识
* @param dataStr 事件数据JSON 字符串)
* @param eventBindConfig 事件绑定配置
* @param showPagePreview 页面预览回调函数
* @param dedupManager 防重复触发管理器(可选)
*/
export const handleMessageEventClick = (
eventType: string,
dataStr: string,
eventBindConfig: EventBindConfig | undefined,
showPagePreview?: (opts: ShowPagePreviewOptions) => void,
dedupManager?: EventDedupManager
): void => {
const eventKey = `${eventType}-${dataStr}`
// 防重复触发
if (dedupManager && dedupManager.isDuplicate(eventKey)) {
return
}
// 解析数据
let parsedData: Record<string, any> = {}
try {
parsedData = JSON.parse(dataStr)
} catch (err) {
console.error('[Event Handler] 数据解析失败:', err)
return
}
// 查找事件配置
const eventConfig = eventBindConfig?.eventConfigs?.find(
(cfg) => cfg.identification === eventType
)
if (!eventConfig) {
console.warn(`[Event Handler] 未找到事件配置: ${eventType}`)
return
}
// === 根据类型执行 ===
switch (eventConfig.type) {
case EventBindResponseActionEnum.Page: {
if (!eventConfig.pageUrl) {
showError(t("Mobile.ButtonWrapper.pagePathConfigError"))
return
}
const pathParams: Record<string, any> = {}
const params: Record<string, any> = {}
eventConfig.args?.forEach((arg) => {
if (arg.inputType === 'Path' && arg.name)
pathParams[arg.name] = parsedData[arg.name] ?? arg.bindValue
if (arg.inputType === 'Query' && arg.name)
params[arg.name] = parsedData[arg.name] ?? arg.bindValue
})
if (checkPathParams(eventConfig.pageUrl, pathParams)) {
const pageUrl = fillPathParams(eventConfig.pageUrl, pathParams)
const fullUri = eventConfig.basePath? `${eventConfig.basePath}${pageUrl}`: pageUrl
// 构建查询字符串
const queryString = objectToQueryString(params)
showPagePreview?.({
uri: queryString ? `${fullUri}?${queryString}` : fullUri,
})
} else {
showError(t("Mobile.ButtonWrapper.pagePathParamConfigError"))
}
break
}
case EventBindResponseActionEnum.Link: {
handleExternalLink(eventConfig.url)
break
}
default:
console.warn(`[Event Handler] 未知的事件类型: ${eventConfig.type}`)
}
}
// ============ 主 Hook ============
export const useMessageEventDelegate = (
containerRef: Ref<HTMLElement | null>,
eventBindConfig?: EventBindConfig,
showPagePreview?: (opts: ShowPagePreviewOptions) => void
) => {
// 创建防重复触发管理器
const dedupManager = new EventDedupManager(1000)
// 👉 点击事件处理(内部封装)
const handleEventClick = (eventType: string, dataStr: string): void => {
handleMessageEventClick(
eventType,
dataStr,
eventBindConfig.value,
showPagePreview,
dedupManager
)
}
// 👉 监听容器点击事件仅H5平台
// #ifdef H5 || WEB
const handleClick = (e: MouseEvent): void => {
const target = e.target as HTMLElement
const eventElement = target.closest('.event') as HTMLElement | null
if (eventElement) {
e.preventDefault()
e.stopPropagation()
const eventType = eventElement.getAttribute('event-type')
const dataStr = eventElement.getAttribute('data')
if (eventType && dataStr) {
handleEventClick(eventType, dataStr)
} else {
console.warn('[Event Delegate] 缺少必要属性', { eventType, dataStr })
}
}
}
// 👉 生命周期挂载与清理
onMounted(() => {
const container = containerRef.value
if (container) {
container.addEventListener('click', handleClick)
}
})
onUnmounted(() => {
const container = containerRef.value
if (container) {
container.removeEventListener('click', handleClick)
}
})
// #endif
}

View File

@@ -0,0 +1,161 @@
/**
* 页面唤醒检测 Hook
*
* 用于检测页面从后台恢复到前台的场景,适配 H5 和小程序
*
* 使用场景:
* - H5页面切换到后台visibilitychange或切换标签页时触发
* - 小程序:需要配合 onShow/onHide 生命周期使用
*
* 使用方式:
* ```typescript
* const { isHidden, cleanup } = usePageResume({
* onResume: () => {
* console.log('页面恢复');
* // 执行恢复逻辑
* },
* onHide: () => {
* console.log('页面进入后台');
* }
* });
*
* // 在 onUnmounted 中调用 cleanup()
* ```
*/
import { ref, onMounted, onUnmounted } from 'vue';
export interface PageResumeOptions {
/** 页面恢复时的回调 */
onResume: () => void;
/** 页面进入后台时的回调(可选) */
onHide?: () => void;
/** 最小隐藏时间(毫秒),超过此时间才触发恢复回调,默认 3000ms */
minHiddenDuration?: number;
}
export interface PageResumeResult {
/** 页面是否处于隐藏状态 */
isHidden: Ref<boolean>;
/** 页面隐藏时间戳 */
hiddenTimestamp: Ref<number>;
/** 清理函数,用于移除事件监听 */
cleanup: () => void;
/** 手动触发恢复检测(用于小程序 onShow */
triggerResume: () => void;
/** 手动触发隐藏检测(用于小程序 onHide */
triggerHide: () => void;
}
/**
* 页面唤醒检测 Hook
*/
export function usePageResume(options: PageResumeOptions): PageResumeResult {
const { onResume, onHide, minHiddenDuration = 3000 } = options;
// 页面是否隐藏
const isHidden = ref<boolean>(false);
// 页面隐藏时间戳
const hiddenTimestamp = ref<number>(0);
// 是否已初始化
let isInitialized = false;
/**
* 处理页面隐藏
*/
const handleHide = (): void => {
console.log('[PageResume] 页面进入后台');
isHidden.value = true;
hiddenTimestamp.value = Date.now();
onHide?.();
};
/**
* 处理页面恢复
*/
const handleResume = (): void => {
if (!isHidden.value) {
return;
}
const now = Date.now();
const hiddenDuration = now - hiddenTimestamp.value;
console.log(`[PageResume] 页面恢复,隐藏时长: ${hiddenDuration}ms`);
// 只有隐藏时间超过阈值才触发恢复回调
if (hiddenDuration >= minHiddenDuration) {
console.log('[PageResume] 超过最小隐藏时长,触发恢复回调');
onResume();
}
// 重置状态
isHidden.value = false;
hiddenTimestamp.value = 0;
};
/**
* H5 visibilitychange 事件处理
*/
const handleVisibilityChange = (): void => {
// #ifdef H5 || WEB
if (document.hidden) {
handleHide();
} else {
handleResume();
}
// #endif
};
/**
* 初始化监听
*/
const init = (): void => {
if (isInitialized) {
return;
}
// #ifdef H5 || WEB
document.addEventListener('visibilitychange', handleVisibilityChange);
console.log('[PageResume] H5 visibilitychange 监听已初始化');
// #endif
isInitialized = true;
};
/**
* 清理监听
*/
const cleanup = (): void => {
// #ifdef H5 || WEB
if (isInitialized) {
document.removeEventListener('visibilitychange', handleVisibilityChange);
console.log('[PageResume] H5 visibilitychange 监听已移除');
}
// #endif
isInitialized = false;
isHidden.value = false;
hiddenTimestamp.value = 0;
};
// 在组件挂载时初始化
onMounted(() => {
init();
});
// 在组件卸载时清理
onUnmounted(() => {
cleanup();
});
return {
isHidden,
hiddenTimestamp,
cleanup,
// 手动触发(用于小程序)
triggerResume: handleResume,
triggerHide: handleHide,
};
}
export default usePageResume;

View File

@@ -0,0 +1,215 @@
<template>
<view class="about-me-container">
<!-- 顶部导航栏 -->
<custom-nav-bar :title="t('Mobile.Profile.title')">
<template v-slot:left>
<view class="header-icon-box" @tap="jumpNavigateBack">
<text class="iconfont icon-a-Chevronleft"></text>
</view>
</template>
</custom-nav-bar>
<view class="header">
<view class="avatar-box">
<image
:src="currentAvatar"
mode="aspectFill"
class="avatar-image"
:alt="t('Mobile.Common.avatarAlt')"
@error="handleImageError"
/>
</view>
<text class="user-name">{{ userInfo?.nickName }}</text>
</view>
<view class="user-info-box">
<view class="info-section">
<text class="label">{{ t("Mobile.Profile.userName") }}</text>
<text class="value text-ellipsis">{{ userInfo?.userName }}</text>
</view>
<view class="info-section">
<text class="label">{{ t("Mobile.Profile.phone") }}</text>
<text class="value">{{ userInfo?.phone }}</text>
</view>
<view class="info-section">
<text class="label">{{ t("Mobile.Profile.email") }}</text>
<text class="value">{{
userInfo?.email || t("Mobile.Profile.pendingBind")
}}</text>
</view>
<!-- 语言切换 -->
<LanguagePicker />
<view class="info-section" v-if="dynamicCode">
<text class="label">{{
t("Mobile.Profile.dynamicCodeExpire", {
expire: dynamicCodeExpire,
})
}}</text>
<text class="value">{{ dynamicCode }}</text>
</view>
<view class="info-section">
<text class="label">{{ t("Mobile.Profile.version") }}</text>
<text class="value">{{ VERSION }}</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { apiUserInfo, apiUserDynamicCode } from "@/servers/account";
import type { UserInfo } from "@/types/interfaces/login";
import defaultAvatar from "@/static/assets/avatar.png";
import { jumpNavigateBack } from "@/utils/common";
import { VERSION } from "@/constants/config";
import { setCurrentPageNavigationBarTitle } from "@/utils/system.uts";
import { useI18n } from "@/utils/i18n";
import LanguagePicker from "./language-picker/language-picker.uvue";
const { t, ensureLangList } = useI18n();
// 用户信息
const userInfo = ref<UserInfo>(null);
// 动态认证码
const dynamicCode = ref("");
// 动态认证码过期时间
const dynamicCodeExpire = ref("");
// 当前显示的头像,默认使用传入的 avatar
const currentAvatar = ref<string>(defaultAvatar);
// 图片加载错误时触发,切换为默认图片
const handleImageError = () => {
currentAvatar.value = defaultAvatar;
};
// 查询当前登录用户信息
const fetchUserInfo = async () => {
const res = await apiUserInfo();
const { code, data } = res || {};
if (code === SUCCESS_CODE) {
userInfo.value = data;
currentAvatar.value = data?.avatar || defaultAvatar;
}
};
// 格式化日期
const formatDate = (date: Date): string => {
const pad = (n: number) => (n < 10 ? `0${n}` : n);
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
// 获取动态认证码
const fetchDynamicCode = async () => {
try {
const res = await apiUserDynamicCode();
if (res.code === SUCCESS_CODE) {
dynamicCode.value = res.data || "";
// 过期时间为当前时间 + 5分钟
const expireTime = new Date(Date.now() + 5 * 60 * 1000);
dynamicCodeExpire.value = formatDate(expireTime);
}
} catch (e) {
console.error("获取动态认证码失败", e);
}
};
onLoad(() => {
fetchUserInfo();
fetchDynamicCode();
// 确保语言列表已加载,供 H5 端的语言切换功能使用
ensureLangList();
// 设置当前页面导航栏标题
setCurrentPageNavigationBarTitle();
});
</script>
<style lang="scss" scoped>
.about-me-container {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #f9f9f9;
.header-icon-box {
display: flex;
align-items: center;
justify-content: center;
width: 56rpx;
height: 56rpx;
.iconfont {
font-size: 32rpx;
color: #666;
}
}
}
.header {
height: 400rpx;
background-color: #fff;
opacity: 0.8;
border-radius: 0 0 20rpx 20rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(
180deg,
rgb(150, 136, 230) 0%,
rgb(164, 170, 232) 100%
);
gap: 30rpx;
.avatar-box {
background-color: white;
border-radius: 50%;
width: 160rpx;
height: 160rpx;
overflow: hidden;
.avatar-image {
width: 100%;
height: 100%;
}
}
.user-name {
font-size: 32rpx;
font-weight: 600;
color: #fff;
}
}
.user-info-box {
margin-top: 20rpx;
background-color: #fff;
padding: 0 30rpx;
.info-section {
height: 80rpx;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 30rpx;
font-size: 28rpx;
color: #333;
border-bottom: 1rpx solid rgba(12, 20, 102, 0.04);
&:last-child {
border-bottom: none;
}
.label {
font-size: 24rpx;
}
.value {
flex: 1;
text-align: right;
font-size: 24rpx;
color: #666;
}
}
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<view class="info-section" v-if="langList.length > 1" @tap="handleOpen">
<text class="label">{{ t("Mobile.Profile.language") }}</text>
<view class="picker-trigger">
<text class="value text-ellipsis">{{ currentLanguageName }}</text>
<text class="iconfont icon-a-Chevrondown"></text>
</view>
</view>
<radio-list-drawer
:visible="visible"
:title="t('Mobile.Profile.selectLanguage')"
:list="languageOptions"
:current-value="localCurrentLang"
@onClose="handleClose"
@onChange="handleLanguageChange"
/>
</template>
<script setup lang="uts">
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { useI18n, normalizeLang } from "@/utils/i18n";
import { RadioListItem } from "@/types/interfaces/radio";
import RadioListDrawer from "@/components/radio-list-drawer/radio-list-drawer.uvue";
const { t, currentLang, langList, setLanguage, loadI18n } = useI18n();
const visible = ref(false);
const localCurrentLang = ref("");
const languageOptions = computed((): RadioListItem[] => {
return langList.value.map((item): RadioListItem => {
return {
label: item.name,
value: normalizeLang(item.lang || ""), // 统一格式,确保与 currentLang 匹配
// tag: item.isDefault ? t("Mobile.Profile.default") : "",
disabled: item.status === 0,
} as RadioListItem;
});
});
const currentLanguageName = computed((): string => {
const normCurrent = normalizeLang(currentLang.value);
const item = langList.value.find(
(item) => normalizeLang(item.lang || "") === normCurrent,
);
return item?.name || item?.lang || t("Mobile.Profile.languagePlaceholder");
});
const handleOpen = () => {
// 移除 await loadI18n() 冗余调用,依赖页面初始化时的加载结果
// 默认选中后端返回的默认语言
const defaultItem = langList.value.find((item) => item.isDefault === 1);
if (defaultItem) {
localCurrentLang.value = normalizeLang(defaultItem.lang || "");
} else {
localCurrentLang.value = normalizeLang(currentLang.value);
}
visible.value = true;
};
const handleClose = () => {
visible.value = false;
};
const handleLanguageChange = async (targetLang: string) => {
if (!targetLang || targetLang === currentLang.value) return;
uni.showLoading({
title: t("Mobile.Common.switching"),
});
try {
const ok = await setLanguage(targetLang);
if (!ok) {
uni.showToast({
title: t("Mobile.Common.switchFailed"),
icon: "none",
});
return;
}
uni.showToast({
title: t("Mobile.Common.switchSuccess"),
icon: "success",
});
// 成功切换后刷新页面,确保全局语言包生效
setTimeout(() => {
// #ifdef H5
// 必须跳转到首页TabBar 页面)再刷新,不能在 about-me非 TabBar 页面)直接 reload。
// 否则 App 重新初始化时 applyTabBarI18n 调用 uni.setTabBarItem 会报错 "not TabBar page"
// 导致导航栏文字无法更新,用户返回首页后依然看到中文 TabBar。
window.location.replace("/");
// #endif
// #ifndef H5
uni.reLaunch({
url: "/pages/index/index",
});
// #endif
}, 500);
} finally {
uni.hideLoading();
visible.value = false;
}
};
</script>
<style lang="scss" scoped>
.info-section {
height: 80rpx;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 30rpx;
font-size: 28rpx;
color: #333;
border-bottom: 2rpx solid rgba(12, 20, 102, 0.04);
&:active {
background-color: #f5f5f5;
}
.label {
font-size: 24rpx;
}
.value {
flex: 1;
text-align: right;
font-size: 24rpx;
color: #666;
}
.picker-trigger {
max-width: 260rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 8rpx;
.iconfont {
font-size: 24rpx;
color: #999;
}
}
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<chat-conversation-component ref="chatConversationComponentRef" />
</template>
<script setup lang="uts">
import ChatConversationComponent from '@/subpackages/pages/chat-conversation-component/chat-conversation-component.uvue'
import { ref, onUnmounted, getCurrentInstance } from 'vue'
import { getCurrentPageFullPath } from '@/utils/common'
import { onAddToFavorites, onShow, onHide } from '@dcloudio/uni-app'
const instance = getCurrentInstance()
onShow(() => {
// 尝试通过实例获取引用,绕过闭包捕获问题
// Const variable "chatConversationComponentRef" might not be captured by onShow in MP environment
const proxy: any = instance?.proxy
const componentRef = proxy?.$refs?.chatConversationComponentRef || proxy?.chatConversationComponentRef
if (componentRef) {
// 在 UTS/小程序中,组件引用可能需要强转或通过特殊方式调用
(componentRef as any).triggerResume?.()
}
})
onHide(() => {
// Const variable "chatConversationComponentRef" might not be captured by onHide in MP environment
const proxy: any = instance?.proxy
const componentRef = proxy?.$refs?.chatConversationComponentRef || proxy?.chatConversationComponentRef
if (componentRef) {
(componentRef as any).triggerHide?.()
}
})
onUnmounted(() => {
/**
* 事件监听, 刷新首页,重新请求我的收藏数据
* 智能体主页中点击收藏或取消收藏后,返回首页,需要刷新首页数据
*/
uni.$emit('refreshData')
})
// #ifdef MP-WEIXIN
// 转发给朋友
onShareAppMessage(()=>{
const proxy: any = instance?.proxy
const componentRef = proxy?.$refs?.chatConversationComponentRef || proxy?.chatConversationComponentRef
return {
title: componentRef?.getAgentInfo()?.name || '',
path: getCurrentPageFullPath(),
}
})
// 收藏
onAddToFavorites(()=>{
const proxy: any = instance?.proxy
const componentRef = proxy?.$refs?.chatConversationComponentRef || proxy?.chatConversationComponentRef
return {
title: componentRef?.getAgentInfo()?.name || '',
}
})
// #endif
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,741 @@
<template>
<view class="search-page-container flex flex-col">
<!-- 微信安全区 -->
<safety-zone></safety-zone>
<!-- 搜索框 -->
<view class="search-container border-b">
<!-- #ifdef MP-WEIXIN -->
<text class="cancel-text" @click="jumpNavigateBack">{{
t("Mobile.Common.cancel")
}}</text>
<!-- #endif -->
<view class="search-box" :class="{ focused: isFocused }">
<text class="iconfont icon-Search"></text>
<input
ref="searchInputRef"
class="search-input"
:placeholder="searchPlaceholder"
placeholder-class="placeholder-class"
type="search"
confirm-type="search"
:value="searchKeyword"
@confirm="handleSearch($event.detail.value)"
@input="handleSearch($event.detail.value)"
@focus="handleFocus"
@blur="handleBlur"
:auto-focus="shouldAutoFocus"
/>
<template v-if="searchKeyword">
<text
class="iconfont icon-a-Xcircle-fill clear-icon"
@click="handleSearch('')"
></text>
</template>
</view>
<!-- #ifndef MP-WEIXIN -->
<text class="cancel-text" @click="jumpNavigateBack">{{
t("Mobile.Common.cancel")
}}</text>
<!-- #endif -->
<!-- 微信胶囊按钮功能区 -->
<!-- #ifdef MP-WEIXIN -->
<view
class="right-safety"
:style="{
width: clientRect.width + 13 + 'px',
height: clientRect.height + 'px',
}"
></view>
<!-- #endif -->
</view>
<!-- 滚动部分 -->
<view class="scroll-section flex-1">
<scroll-view
class="h-full"
@scrolltolower="handleLoadMore"
:refresher-enabled="searchMode === 'api'"
:refresher-triggered="refreshing || false"
@refresherrefresh="handleRefresh"
:show-scrollbar="false"
>
<template v-if="(agentList || []).length">
<template v-for="info in agentList" :key="info.id">
<!-- 页面应用 -->
<page-card
v-if="cardComponentType === 'PageApp'"
:coverImg="info.coverImg"
:name="info.name"
:userName="info.publishUser?.userName"
:created="info.created"
@click="handleAgentClick(info)"
/>
<!-- 首页 -->
<recent-used-agent-item
v-else-if="cardComponentType === 'Home'"
:icon="info.icon"
:name="info.name"
:description="info.description"
:paymentRequired="info.paymentRequired && enableSubscription === 1"
:subscribed="info.subscribed"
@click="handleRecentUsedAgentClick(info)"
/>
<!-- 聊天机器人: 默认 cardComponentType 为 ChatBot -->
<agent-component
v-else
:icon="info.icon"
:name="info.name"
:avatar="info.publishUser?.avatar"
:userName="
info.publishUser?.nickName || info.publishUser?.userName
"
:description="info.description"
:userCount="info.statistics?.userCount"
:convCount="info.statistics?.convCount"
:collectCount="info.statistics?.collectCount"
:paymentRequired="info.paymentRequired && enableSubscription === 1"
:subscribed="info.subscribed"
@click="handleAgentClick(info)"
/>
</template>
</template>
<template v-if="loading">
<view class="loading-state">
<text class="loading-text">{{ t("Mobile.Page.loadingMore") }}</text>
</view>
</template>
<template
v-else-if="!loading && searchKeyword && agentList?.length === 0"
>
<view class="empty-state">
<image
:src="noData"
class="empty-image"
:alt="t('Mobile.Common.noDataImageAlt')"
/>
<text class="empty-text">{{
t("Mobile.AgentSearch.emptyResult")
}}</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>
</view>
</template>
<script lang="uts" setup>
import { apiPublishedAgentList } from "@/servers/square";
import { apiUserUsedAgentList } from "@/servers/agentDev";
import {
SquarePublishedListParams,
SquarePublishedItemInfo,
} from "@/types/interfaces/square";
import { SquareAgentTypeEnum } from "@/types/enums/square";
import { Page, RequestResponse } from "@/types/interfaces/request";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import { AgentComponentTypeEnum } from "@/types/enums/agent";
import RecentUsedAgentItem from "@/components/recent-used-agent-item/recent-used-agent-item.uvue";
import { apiTenantConfig } from "@/servers/account";
import { TENANT_CONFIG_INFO } from "@/constants/home.constants";
import type { TenantConfigInfo } from "@/types/interfaces/login";
import {
jumpToAgentDetailPage,
getCurrentPagePath,
} from "@/utils/commonBusiness";
import type { AgentInfo } from "@/types/interfaces/agent";
import noData from "@/static/assets/no_data.png";
// import { jumpNavigateBack } from '@/utils/common'
import type { CategoryItemInfo } from "@/types/interfaces/agentConfig";
import { AllowCopyEnum } from "@/types/enums/agent";
import { PluginTypeEnum } from "@/types/enums/plugin";
import { isTabPage } from "@/utils/commonBusiness";
import { setCurrentPageNavigationBarTitle } from "@/utils/system.uts";
import { useI18n, translateText } from "@/utils/i18n";
const { t } = useI18n();
const searchKeyword = ref<string>("");
const searchTimeoutRef = ref<number | null>(null);
const agentList = ref<SquarePublishedItemInfo[] | AgentInfo[]>([]);
const hasMore = ref<boolean>(true);
const page = ref<number>(1);
const loading = ref<boolean>(false);
const refreshing = ref<boolean>(false);
// 搜索输入框ref
const searchInputRef = ref<UniInputElement | null>(null);
const isFocused = ref<boolean>(false);
const cardComponentType = ref<"Home" | "PageApp" | "ChatBot">("ChatBot");
// 租户信息
const tenantConfigInfo = ref<TenantConfigInfo | null>(null);
// 租户订阅限制强受控标志
const enableSubscription = computed((): number => {
return tenantConfigInfo.value?.enableSubscription ?? 0;
});
// 搜索模式:'api' 接口搜索模式,'local' 前端过滤模式
const searchMode = ref<"api" | "local">("api");
// 前端过滤模式下的原始列表数据
const localAgentList = ref<CategoryItemInfo[]>([]);
// 搜索框 placeholder key运行时翻译保证语言切换可生效
const searchPlaceholderSource = ref<string>("Mobile.AgentSearch.placeholder");
const searchPlaceholder = computed(() => {
// 默认占位符 Key
let placeholderKey = "Mobile.AgentSearch.placeholder";
// 如果是 PageApp 类型,切换 Key
if (cardComponentType.value === "PageApp") {
placeholderKey = "Mobile.AgentSearch.pageAppPlaceholder";
}
// 如果 searchPlaceholderSource 有值且不是默认值(通常是 local 模式下从外部传入),则优先使用它
const finalKey =
searchPlaceholderSource.value &&
searchPlaceholderSource.value !== "Mobile.AgentSearch.placeholder"
? searchPlaceholderSource.value
: placeholderKey;
return translateText(finalKey);
});
// 小程序平台控制 auto-focus
const shouldAutoFocus = ref<boolean>(false);
// 返回地址
const backUrl = ref<string>("");
// #ifdef MP-WEIXIN
const clientRect = uni.getMenuButtonBoundingClientRect();
// #endif
const jumpNavigateBack = () => {
const toUrl = backUrl.value ? backUrl.value : "/pages/index/index";
console.log(toUrl);
if (isTabPage(toUrl)) {
uni.switchTab({ url: toUrl });
} else {
uni.redirectTo({ url: toUrl });
}
};
// 获取租户配置
const fetchTenantConfig = async () => {
const { code, data } = await apiTenantConfig();
if (code === SUCCESS_CODE) {
tenantConfigInfo.value = data;
// 缓存租户信息
uni.setStorageSync(TENANT_CONFIG_INFO, JSON.stringify(data));
}
};
// 初始化租户配置数据
const initTenantConfig = async () => {
// 1. 首先尝试从本地缓存读取
const tenantConfigInfoString = await uni.getStorageSync(TENANT_CONFIG_INFO);
if (tenantConfigInfoString) {
tenantConfigInfo.value = JSON.parse(tenantConfigInfoString);
}
// 2. 然后必须发起网络强同步请求,以获得最新的 enableSubscription 状态
await fetchTenantConfig();
};
onLoad(
async (options: {
type: "Home" | "PageApp" | "ChatBot";
searchMode?: "api" | "local";
backUrl?: string;
}) => {
// 强同步获取并更新租户配置信息,保障收费 tag 状态的百分百实时受控
await initTenantConfig();
const { type, searchMode: mode } = options;
backUrl.value = options.backUrl
? decodeURIComponent(options.backUrl)
: "";
cardComponentType.value = type;
// 设置当前页面导航栏标题
setCurrentPageNavigationBarTitle();
// 检测 searchMode 参数,区分搜索模式
if (mode === "local") {
// 从 globalData 获取列表数据和文案
const app = getApp();
// 兜底逻辑:如果 globalData 没有相关数据,回退到 api 模式
if (
app.globalData &&
app.globalData.localAgentList &&
Array.isArray(app.globalData.localAgentList) &&
app.globalData.localAgentList.length > 0
) {
// 有数据,使用前端过滤模式
searchMode.value = "local";
localAgentList.value = app.globalData
.localAgentList as CategoryItemInfo[];
// 默认不显示全部列表,只有搜索时才显示
agentList.value = [];
hasMore.value = false;
// 获取搜索文案,如果没有则使用默认值
if (app.globalData.searchPlaceholder) {
searchPlaceholderSource.value = app.globalData
.searchPlaceholder as string;
}
} else {
// 没有数据,回退到 api 模式
searchMode.value = "api";
}
} else {
searchMode.value = "api";
}
},
);
onMounted(() => {
// 页面加载完成后自动获取输入框焦点
// #ifndef MP-WEIXIN
// 非小程序平台可以直接调用 focus
nextTick(() => {
if (
searchInputRef.value &&
typeof searchInputRef.value.focus === "function"
) {
searchInputRef.value.focus();
}
});
// #endif
});
// 小程序平台使用 onReady 确保页面渲染完成后再聚焦
onReady(() => {
// #ifdef MP-WEIXIN
// 小程序平台:通过设置 auto-focus 为 true 来触发聚焦
shouldAutoFocus.value = true;
// #endif
});
// 小程序页面显示时也尝试聚焦
onShow(() => {
// #ifdef MP-WEIXIN
// 小程序平台:通过重置 auto-focus 来重新触发聚焦
shouldAutoFocus.value = false;
nextTick(() => {
shouldAutoFocus.value = true;
});
// #endif
});
// 是否显示没有更多数据(前端过滤模式下不显示)
const showNoMore = computed(() => {
if (searchMode.value === "local") {
return false;
}
return !loading.value && !hasMore.value && agentList.value?.length > 8;
});
/**
* 将 CategoryItemInfo 转换为 SquarePublishedItemInfo 格式
* @param items CategoryItemInfo 数组
* @returns SquarePublishedItemInfo 数组
*/
const convertCategoryItemsToSquareItems = (
items: CategoryItemInfo[],
): SquarePublishedItemInfo[] => {
return items.map((item) => ({
id: item.targetId, // 使用 targetId 作为 id
tenantId: 0,
spaceId: 0,
targetType: item.targetType as SquareAgentTypeEnum,
agentType: item.agentType,
targetId: item.targetId,
name: item.name,
description: item.description || null,
icon: item.icon,
remark: "",
modified: "",
created: "",
statistics: item.statistics,
publishUser: item.publishUser,
category: "",
allowCopy: AllowCopyEnum.No,
pluginType: PluginTypeEnum.HTTP,
collect: item.collect,
paymentRequired: (item as any).paymentRequired,
subscribed: (item as any).subscribed,
}));
};
const handleFocus = () => {
isFocused.value = true;
};
const handleBlur = () => {
isFocused.value = false;
};
// 获取智能体、页面应用列表
const fetchAgentList = async (
pageNum: number = 1,
keyword: string = searchKeyword.value,
) => {
const params: SquarePublishedListParams = {
targetType: AgentComponentTypeEnum.Agent,
targetSubType:
cardComponentType.value === "PageApp" ? "PageApp" : "ChatBot",
page: pageNum,
pageSize: 10,
category: "",
kw: keyword,
};
// 获取智能体列表
const response = await apiPublishedAgentList(params);
const { code, data, message } = response as unknown as RequestResponse<
Page<SquarePublishedItemInfo>
>;
// 成功
if (code === SUCCESS_CODE) {
const newList = data.records || [];
if (pageNum > 1) {
agentList.value = [...agentList.value, ...newList];
} else {
agentList.value = newList;
}
hasMore.value = pageNum < data.pages;
page.value = pageNum;
loading.value = false;
} else {
uni.showToast({
title: message || t("Mobile.AgentSearch.fetchAgentFailed"),
icon: "error",
});
loading.value = false;
}
};
// 查询用户最近使用过的智能体列表
const fetchUserUsedAgentList = async (
pageNum: number = 1,
keyword: string = searchKeyword.value,
) => {
const res = await apiUserUsedAgentList({
pageIndex: pageNum,
size: 20,
keyword,
});
const { code, data } = res || {};
if (code === SUCCESS_CODE) {
const newList = data || [];
if (pageNum > 1) {
// 追加数据
agentList.value = [...(agentList.value || []), ...newList];
} else {
// 替换数据
agentList.value = newList;
}
// 是否有更多数据, 如果数据长度大于0则有更多数据
hasMore.value = newList?.length > 0;
page.value = pageNum;
loading.value = false;
} else {
uni.showToast({
title: t("Mobile.AgentSearch.fetchRecentUsedFailed"),
icon: "none",
});
loading.value = false;
}
};
// 获取智能体列表
const fetchList = async (
pageNum: number = 1,
keyword: string = searchKeyword.value,
) => {
// 前端过滤模式下不调用接口
if (searchMode.value === "local") {
return;
}
if (cardComponentType.value === "Home") {
await fetchUserUsedAgentList(pageNum, keyword);
} else {
await fetchAgentList(pageNum, keyword);
}
};
/**
* 前端过滤搜索(本地过滤)
* @param keyword 搜索关键词
*/
const filterLocalList = (keyword: string) => {
// 如果没有关键词,返回空列表
if (!keyword || !localAgentList.value.length) {
agentList.value = [];
return;
}
// 对本地列表进行过滤,匹配 name 或 description不区分大小写
const lowerKeyword = keyword.toLowerCase();
const filtered = localAgentList.value.filter((item) => {
const nameMatch =
item.name?.toLowerCase().includes(lowerKeyword) || false;
const descMatch =
item.description?.toLowerCase().includes(lowerKeyword) || false;
return nameMatch || descMatch;
});
agentList.value = convertCategoryItemsToSquareItems(filtered);
};
// 处理搜索
const handleSearch = (keyword: string) => {
searchKeyword.value = keyword;
// 清除之前的定时器
if (searchTimeoutRef.value) {
clearTimeout(searchTimeoutRef.value);
}
// 如果搜索关键词为空
if (!keyword) {
// 前端过滤模式和接口搜索模式都清空列表
agentList.value = [];
loading.value = false;
// #ifdef H5 || WEB
nextTick(() => {
if (searchInputRef.value) {
searchInputRef.value?.focus();
}
});
// #endif
return;
}
// 根据搜索模式选择不同的处理方式
if (searchMode.value === "local") {
// 前端过滤模式:本地过滤
loading.value = true;
// 设置新的定时器防抖500ms
searchTimeoutRef.value = setTimeout(() => {
filterLocalList(keyword);
loading.value = false;
}, 500);
} else {
// 接口搜索模式:调用接口
loading.value = true;
// 设置新的定时器防抖500ms
searchTimeoutRef.value = setTimeout(() => {
page.value = 1;
hasMore.value = true;
fetchList(1, keyword);
}, 500);
}
};
// 加载更多
const handleLoadMore = () => {
// 前端过滤模式下禁用加载更多
if (searchMode.value === "local") {
return;
}
if (hasMore.value && !loading.value) {
const nextPage = page.value + 1;
fetchList(nextPage);
}
};
// 下拉刷新
const handleRefresh = () => {
// 前端过滤模式下禁用下拉刷新
if (searchMode.value === "local") {
refreshing.value = false;
return;
}
if (refreshing.value) {
return;
}
refreshing.value = true;
fetchList().finally(() => {
refreshing.value = false;
});
};
// 跳转到智能体详情页
const handleAgentClick = (info: SquarePublishedItemInfo) => {
jumpToAgentDetailPage(info.targetId, null, info.agentType, info.name);
};
// 跳转到智能体详情页
const handleRecentUsedAgentClick = (info: AgentInfo) => {
jumpToAgentDetailPage(info.agentId, null, info.agentType, info.name);
};
</script>
<style lang="scss" scoped>
.search-page-container {
height: 100vh;
overflow: hidden;
background-color: #fff;
/* #ifdef MP-WEIXIN */
// padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
/* #endif */
/* 搜索框 */
.search-container {
display: flex;
flex-direction: row;
align-items: center;
padding: 10rpx 0 10rpx 32rpx;
flex-shrink: 0;
.search-box {
flex: 1;
gap: 16rpx;
display: flex;
align-items: center;
flex-direction: row;
height: 76rpx;
padding: 0 24rpx;
border-radius: 16rpx;
border: 2rpx solid transparent;
background: rgba(12, 20, 102, 0.04);
transition: border-color 0.3s ease;
&.focused {
border-color: #5147ff;
}
.iconfont {
font-size: 32rpx;
color: #666;
}
.search-input {
flex: 1;
border: none;
font-size: 28rpx;
color: #15171f;
outline: none;
font-weight: 400;
}
.placeholder-class {
color: rgba(21, 23, 31, 0.5);
font-size: 28rpx;
font-weight: 400;
}
.clear-icon {
font-size: 32rpx;
color: #999 !important;
cursor: pointer;
}
}
.cancel-text {
display: flex;
align-items: center;
justify-content: center;
height: 76rpx;
font-weight: 400;
padding: 0 32rpx;
color: rgba(21, 23, 31, 0.5);
font-size: 32rpx;
transition: background-color 0.3s ease;
border-radius: 8rpx;
&:active {
background-color: rgba(12, 20, 102, 0.04);
}
/* #ifdef H5 */
&:hover {
cursor: pointer;
}
/* #endif */
}
}
/* 滚动部分 */
.scroll-section {
flex: 1;
overflow: hidden;
/* 空状态 */
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.empty-image {
width: 172rpx;
height: 172rpx;
}
.empty-text {
font-size: 32rpx;
color: #15171f;
font-weight: 400;
margin-top: 32rpx;
}
}
/* 加载状态 */
.loading-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
.loading-text {
font-size: 28rpx;
color: #666;
display: flex;
align-items: center;
}
.loading-text::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;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 没有更多数据状态 */
.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,24 @@
<template>
<web-view :src="src" @message="onMessage"></web-view>
</template>
<script>
import { API_BASE_URL } from "@/constants/config";
const path = "/static/aliyun-captcha.html";
export default {
computed: {
src() {
return API_BASE_URL + path;
},
},
methods: {
onMessage(e) {
const captchaVerifyParam = e.detail.data[0];
const eventChannel = this.getOpenerEventChannel();
// 通过事件通道,触发在业务页面中定义的 'getCaptchaVerifyParam' 事件,并把验证参数发送至业务页面
eventChannel &&
eventChannel.emit("getCaptchaVerifyParam", captchaVerifyParam);
},
},
};
</script>

View File

@@ -0,0 +1,83 @@
<template>
<chat-conversation-component @menu-click="onMenuClick" :is-app-details="true" />
<!-- 使用封装好的弹框组件 -->
<drawer-popup ref="popup" :show-header="false">
<history-conversation-popup
:user="user"
:chat-list="conversationList"
/>
</drawer-popup>
</template>
<script setup lang="uts">
import ChatConversationComponent from '@/subpackages/pages/chat-conversation-component/chat-conversation-component.uvue'
// 历史会话弹框
import HistoryConversationPopup from './history-conversation-popup/history-conversation-popup.uvue'
import DrawerPopup from '@/components/drawer-popup/drawer-popup.uvue'
// API调用
import { SUCCESS_CODE } from '@/constants/codes.constants.uts'
import { t } from '@/utils/i18n'
import { apiUserInfo } from '@/servers/account'
import { apiUserUsedAgentList } from '@/servers/agentDev'
import { apiAgentConversationList } from '@/servers/conversation'
// 弹出层
interface DrawerPopupRef {
open: () => void
close: () => void
}
const popup = ref<DrawerPopupRef | null>(null)
// 当前智能体id
const currentAgentId = ref<number | null>(null)
// 弹出层数据状态
const user = reactive<{avatar: string, nickname: string}>({
avatar: "/static/logo.png",
nickname: "--"
});
// 用户历史会话列表
const conversationList = ref<ConversationInfo[]>([]);
// 查询用户信息
const fetchUserInfo = async () => {
const {code, data} = await apiUserInfo()
if(code === SUCCESS_CODE){
if (data.avatar) {
user.avatar = data.avatar
}
user.nickname = data.nickName || data.userName
}
}
// 查询用户历史会话
const fetchUserConversationList = async () => {
const {code, data} = await apiAgentConversationList({agentId: currentAgentId.value})
if(code === SUCCESS_CODE){
conversationList.value = data
}
}
onLoad((options) => {
currentAgentId.value = options?.id ? Number(options.id) : null
})
// 打开菜单弹框
const onMenuClick = async () => {
try{
await Promise.all([
fetchUserInfo(),
fetchUserConversationList()
])
popup.value?.open()
} catch (error) {
console.error(error)
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,190 @@
<template>
<view class="sidebar">
<scroll-view class="content-scroll" scroll-y="true" :show-scrollbar="false">
<!-- 用户信息 -->
<view class="user-info">
<image :src="props.user?.avatar" class="avatar" mode="aspectFill" :alt="t('Mobile.Common.avatarAlt')"></image>
<text class="nickname text-ellipsis">{{ props.user?.nickname }}</text>
</view>
<!-- 会话记录 -->
<view class="session-recording">
<view class="section-header">
<view class="section-title">{{ t('Mobile.Common.conversationHistory') }}</view>
</view>
<custom-nav-bar-speak :chat-list="props.chatList" :is-app-details="true" />
</view>
</scroll-view>
<!-- 退出 -->
<view class="logout">
<view class="logout-item" @click="handleLogout">
<text class="iconfont icon-Exit"></text>
<text class="logout-text">{{ t('Mobile.Header.logout') }}</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { ConversationInfo} from '@/types/interfaces/conversationInfo';
import { SUCCESS_CODE } from '@/constants/codes.constants';
import { apiLogout } from '@/servers/account';
import { t } from '@/utils/i18n';
import CustomNavBarSpeak from '@/components/custom-nav-bar/custom-nav-bar-speak/custom-nav-bar-speak.uvue'
// 定义props
const props = defineProps<{
user: {avatar: string, nickname: string},
chatList: ConversationInfo[]
}>()
// 对象转 query 字符串
function objectToQuery(obj) {
return Object.keys(obj)
.map(key => `${key}=${encodeURIComponent(obj[key])}`)
.join('&');
}
// 获取当前页面信息
function getCurrentPageInfo() {
// #ifdef H5 || WEB
/**
* 此处获取完整路径因为H5端页面跳转redirectTo方法中需要完整路径
*/
return window.location.href;
// #endif
// #ifdef MP-WEIXIN
const pages = getCurrentPages();
if (pages.length === 0) return '';
// 获取当前页面
const current = pages[pages.length - 1];
// 判断参数对象是否为空
const isEmptyObject = Object.keys(current.options).length === 0;
// current.route 页面路径,如 pages/index/index
// current.options 参数对象,如 {id: "123", conversationId: "456"}
// fullPath 完整路径,如 /pages/index/index?id=123&conversationId=456
return isEmptyObject ? `/${current.route}` : `/${current.route}?${objectToQuery(current.options)}`;
// #endif
}
// 退出登录
const handleLogout = async ()=>{
try {
const result = await apiLogout()
if (result.code === SUCCESS_CODE) {
uni.showToast({
title: t('Mobile.Header.logoutSuccess'),
icon: 'success'
})
uni.clearStorageSync()
// 获取当前页面完整路径
const fullPath = getCurrentPageInfo();
// #ifdef H5 || WEB
const url = `/subpackages/pages/login/login?redirect=${encodeURIComponent(fullPath)}`;
uni.reLaunch({ url });
// #endif
// #ifdef MP-WEIXIN
uni.reLaunch({ url: `/subpackages/pages/login-weixin/login-weixin?redirect=${encodeURIComponent(fullPath)}` });
// #endif
}
} catch (error) {
uni.showToast({
title: t('Mobile.Header.logoutFailed'),
icon: 'none'
})
return
}
}
</script>
<style lang="scss" scoped>
.sidebar {
width: 100%;
height: 100%;
background-color: #fff;
box-sizing: border-box;
display: flex;
flex-direction: column;
.content-scroll {
flex: 1;
min-height: 0;
}
// 用户信息
.user-info {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 40rpx;
padding: 0 16rpx 0 32rpx;
.avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
margin-right: 20rpx;
}
.nickname {
flex:1;
font-size: 36rpx;
font-weight: bold;
}
}
// 会话记录
.session-recording {
padding: 0 16rpx;
.section-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 16rpx;
margin-bottom: 16rpx;
.section-title {
flex: 1;
font-size: 28rpx;
font-weight: bold;
color: rgba(21, 23, 31, 0.5);
}
}
}
// 退出
.logout {
flex-shrink: 0;
padding: 20rpx 0;
border-top: 2rpx solid rgba(21, 23, 31, 0.06);
background-color: #fff;
.logout-item {
margin: 0 auto;
display: flex;
flex-direction: row;
align-items: center;
.iconfont {
font-size: 40rpx;
margin-right: 10rpx;
color: rgba(21, 23, 31, 0.7) !important;
}
.logout-text {
font-size: 28rpx;
line-height: 44rpx;
color: rgba(21, 23, 31, 0.7);
}
}
}
}
</style>

View File

@@ -0,0 +1,242 @@
<template>
<view class="container flex flex-col h-full">
<custom-nav-bar :title="category?.name">
<template v-slot:left>
<text
class="iconfont icon-a-Chevronleft"
@tap="jumpNavigateBack"
></text>
</template>
</custom-nav-bar>
<scroll-view class="flex-1" direction="vertical" :show-scrollbar="false">
<template v-if="loading">
<view class="loading-state">
<text class="loading-text">{{
t("Mobile.Page.loadingMore")
}}</text>
</view>
</template>
<template v-else>
<view
v-for="item in categoryItems"
:key="item.targetId"
class="flex flex-row items-center item-card"
hover-class="item-card-active"
@click="handleAgentClick(item)"
>
<image
class="item-card__icon"
:src="getImageSrc(item.targetId, item.icon)"
mode="aspectFill"
:alt="item.name || t('Mobile.Common.agentIconAlt')"
@error="handleImageError(item.targetId)"
/>
<view class="item-card__content">
<text class="item-card__name text-ellipsis">{{ item.name }}</text>
<text class="item-card__desc text-ellipsis">{{
item.description
}}</text>
</view>
</view>
</template>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import agentImage from "@/static/assets/agent_image.png";
import { jumpToAgentDetailPage } from "@/utils/commonBusiness";
// import { jumpNavigateBack } from '@/utils/common'
import {
SUCCESS_CODE,
USER_NO_LOGIN,
REDIRECT_LOGIN,
} from "@/constants/codes.constants.uts";
import { apiHomeCategoryList } from "@/servers/agentDev";
import {
CategoryInfo,
CategoryItemInfo,
} from "@/types/interfaces/agentConfig";
import { onAddToFavorites } from "@dcloudio/uni-app";
import { setCurrentPageNavigationBarTitle } from "@/utils/system.uts";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
const loading = ref<boolean>(false);
const categoryKey = ref<string>(""); // 分类key
// 分类信息
const category = ref<CategoryInfo>(null);
// 分类下的智能体列表
const categoryItems = ref<CategoryItemInfo[]>([]);
// 记录加载失败的图片ID
const failedImageIds = reactive<Set<string>>(new Set());
// 新增标志变量,用于区分首次加载
const isFirstLoad = ref<boolean>(true);
const jumpNavigateBack = () => {
uni.switchTab({ url: "/pages/agent-union-record/agent-union-record" });
};
/**
* 获取图片源地址
* @param id 智能体ID
* @param originalIcon 原始图片地址
* @returns 图片地址(加载失败则返回默认图片)
*/
const getImageSrc = (id: string, originalIcon: string): string => {
return failedImageIds.has(id) ? agentImage : originalIcon;
};
/**
* 图片加载错误时触发,切换为默认图片
* @param id 智能体ID
*/
const handleImageError = (id: string) => {
failedImageIds.add(id);
};
// 主页智能体列表接口
const fetchHomeCategoryList = async () => {
const response = await apiHomeCategoryList();
loading.value = false;
const { code, data, message } = response || {};
if (code === SUCCESS_CODE) {
if (data) {
category.value = data.categories?.find(
(item: CategoryInfo) => item.type === categoryKey.value,
);
categoryItems.value = data.categoryItems?.[categoryKey.value] || [];
}
} else if (code !== USER_NO_LOGIN && code !== REDIRECT_LOGIN) {
uni.showToast({
title: message || t("Mobile.AgentUnion.fetchHomeAgentListFailed"),
icon: "none",
});
}
};
onPageShow((options) => {
if (isFirstLoad.value) {
isFirstLoad.value = false;
return;
}
fetchHomeCategoryList();
});
onLoad((options: { categoryKey: string }) => {
// 设置当前页面导航栏标题
setCurrentPageNavigationBarTitle();
const { categoryKey: _categoryKey } = options;
categoryKey.value = _categoryKey;
isFirstLoad.value = true;
loading.value = true;
fetchHomeCategoryList();
});
// 点击智能体
const handleAgentClick = (info: AgentInfo) => {
const { lastConversationId, targetId, name, agentType } = info || {};
if (lastConversationId) {
jumpToAgentDetailPage(targetId, lastConversationId);
} else {
jumpToAgentDetailPage(targetId, null, agentType, name);
}
};
// 收藏
onAddToFavorites(() => {
return {
title: category.value?.name || t("Mobile.Common.appName"),
};
});
</script>
<style lang="scss" scoped>
.container {
overflow: hidden;
background-color: #fff;
/* #ifdef MP-WEIXIN */
// padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
/* #endif */
/* 加载状态 */
.loading-state {
width: 100%;
height: 100%;
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;
}
}
}
.item-card {
display: flex;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1px solid rgba(12, 20, 102, 0.04);
&-active {
background-color: rgba(12, 20, 102, 0.04);
}
&__icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 24rpx;
}
&__content {
flex: 1;
}
&__name {
font-size: 32rpx;
color: #333;
}
&__desc {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
&__count {
font-size: 28rpx;
color: #666;
}
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
<template>
<chat-conversation-component :is-temp-chat="true" />
</template>
<script setup lang="uts">
import ChatConversationComponent from '@/subpackages/pages/chat-conversation-component/chat-conversation-component.uvue'
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,233 @@
<template>
<view class="credit-records-container">
<!-- 顶部导航栏 -->
<custom-nav-bar :title="t('Mobile.CreditRecords.pageTitle')">
<template v-slot:left>
<view class="header-icon-box" @tap="jumpNavigateBack">
<text class="iconfont icon-a-Chevronleft"></text>
</view>
</template>
</custom-nav-bar>
<!-- 分类筛选横向滑动 Tab 栏 -->
<scroll-view
direction="horizontal"
class="filter-tabs-bar"
:show-scrollbar="false"
>
<view
v-for="tab in filterTabs"
:key="tab.value"
class="filter-tab-item"
:class="{ active: currentType === tab.value }"
@tap="handleSelectType(tab.value)"
>
<text class="tab-label" :class="{ active: currentType === tab.value }">
{{ t(tab.label) }}
</text>
</view>
</scroll-view>
<!-- 列表展示区域,使用 scroll-view 支持下拉刷新和上拉触底加载 -->
<scroll-view
scroll-y
class="list-scroll-view"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
@scrolltolower="handleLoadMore"
>
<view v-if="list.length > 0" class="list-content">
<view v-for="item in list" :key="item.id" class="record-card">
<!-- 左侧流水描述、时间以及增加/扣减标签 -->
<view class="card-left">
<text class="record-name">
{{
item.remark != null && item.remark !== ""
? item.remark
: item.creditTypeName
}}
</text>
<text class="record-time">
{{ formatDate(item.created, "YYYY-MM-DD HH:mm:ss") }}
</text>
<view class="badge-container">
<view
class="record-badge"
:class="
item.operationType === 1 ? 'badge-increase' : 'badge-decrease'
"
>
<text
class="badge-text"
:class="
item.operationType === 1 ? 'text-increase' : 'text-decrease'
"
>
{{
item.operationType === 1
? t("Mobile.CreditRecords.badgeIncrease")
: t("Mobile.CreditRecords.badgeDecrease")
}}
</text>
</view>
</view>
</view>
<!-- 右侧变化额度与变动后积分余额 -->
<view class="card-right">
<text
class="record-amount"
:class="{
'amount-increase': item.operationType === 1,
'amount-decrease': item.operationType === 2,
}"
>
{{ item.operationType === 1 ? "+" : "-"
}}{{ formatNumber(item.amount, 2) }}
</text>
<text class="record-balance">
{{
t("Mobile.CreditRecords.remaining") +
" " +
formatNumber(item.afterAmount, 2)
}}
</text>
</view>
</view>
<!-- 触底加载更多 Loading 提示 -->
<view v-if="loading && !refreshing" class="loading-state">
<text class="loading-text">{{ t("Mobile.Page.loadingMore") }}</text>
</view>
<!-- 已加载完毕提示 -->
<view v-if="!hasMore && list.length > 0" class="no-more-state">
<text class="no-more-text">{{ t("Mobile.Common.noMoreData") }}</text>
</view>
</view>
<!-- 空数据状态 -->
<view v-else-if="!loading" class="empty-wrapper">
<empty-state text="Mobile.CreditRecords.empty" />
</view>
<!-- 首次加载的 loading -->
<view v-else class="loading-state">
<text class="loading-text">{{ t("Mobile.Page.loadingMore") }}</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import type { CreditRecordInfo } from "@/subpackages/types/interfaces/subscription";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { apiGetCreditFlows } from "@/subpackages/servers/subscription";
import { jumpNavigateBack } from "@/utils/common";
import { formatDate, formatNumber } from "@/utils/system";
import { useI18n } from "@/utils/i18n";
import EmptyState from "@/components/empty-state/empty-state.uvue";
const { t } = useI18n();
// 筛选 Tab 配置词条
interface TabOption {
label: string;
value: string;
}
const filterTabs: TabOption[] = [
{ label: "Mobile.Common.all", value: "" },
{ label: "Mobile.CreditRecords.typeSubscription", value: "SUBSCRIPTION" },
{ label: "Mobile.CreditRecords.typePurchase", value: "PURCHASE" },
{ label: "Mobile.CreditRecords.typeActivity", value: "ACTIVITY" },
{ label: "Mobile.CreditRecords.typeManual", value: "MANUAL" },
{ label: "Mobile.CreditRecords.typeModelCall", value: "MODEL_CALL" },
{ label: "Mobile.CreditRecords.typeAgentCall", value: "AGENT_CALL" },
{ label: "Mobile.CreditRecords.typeToolCall", value: "TOOL_CALL" },
{ label: "Mobile.CreditRecords.typeManualDeduct", value: "MANUAL_DEDUCT" },
];
// 状态属性定义
const list = ref<CreditRecordInfo[]>([]);
const loading = ref<boolean>(false);
const refreshing = ref<boolean>(false);
const currentType = ref<string>("");
const lastId = ref<number | null>(null);
const hasMore = ref<boolean>(true);
// 获取积分明细数据
const fetchPageData = async (isFirst: boolean) => {
if (loading.value) return;
loading.value = true;
try {
const params: Record<string, any> = {
pageSize: 30,
};
if (currentType.value !== "") {
params["creditType"] = currentType.value;
}
if (!isFirst && lastId.value != null) {
params["lastId"] = lastId.value;
}
const res = await apiGetCreditFlows(params);
if (res.code === SUCCESS_CODE && res.data != null) {
const newData = res.data!;
if (isFirst) {
list.value = newData;
} else {
list.value = [...list.value, ...newData];
}
if (newData.length > 0) {
lastId.value = newData[newData.length - 1].id;
}
hasMore.value = newData.length === 30;
}
} catch (e) {
console.error("加载积分明细异常:", e);
} finally {
loading.value = false;
refreshing.value = false;
}
};
// 下拉刷新事件
const handleRefresh = () => {
refreshing.value = true;
lastId.value = null;
hasMore.value = true;
fetchPageData(true);
};
// 上拉触底加载
const handleLoadMore = () => {
if (!hasMore.value || loading.value) return;
fetchPageData(false);
};
// 切换筛选类型
const handleSelectType = (value: string) => {
if (currentType.value === value) return;
currentType.value = value;
lastId.value = null;
hasMore.value = true;
list.value = [];
fetchPageData(true);
};
// 页面生命周期载入
onLoad(() => {
fetchPageData(true);
});
</script>
<style lang="scss" scoped>
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,196 @@
.credit-records-container {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f9fa;
overflow: hidden;
.header-icon-box {
display: flex;
align-items: center;
justify-content: center;
width: 56rpx;
height: 56rpx;
.iconfont {
font-size: 32rpx;
color: #333333;
}
}
.filter-tabs-bar {
display: flex;
flex-direction: row;
background: #ffffff;
padding: 20rpx 0rpx;
border-bottom: 1rpx solid #e2e8f0;
width: 100%;
.filter-tab-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 12rpx 28rpx;
background: #f1f5f9;
border-radius: 100rpx;
border: 1rpx solid transparent;
margin-left: 16rpx;
&:first-child {
margin-left: 24rpx;
}
&:last-child {
margin-right: 24rpx;
}
&.active {
background: rgba(81, 71, 255, 0.08);
border-color: rgba(81, 71, 255, 0.2);
}
.tab-label {
font-size: 24rpx;
color: #64748b;
font-weight: 500;
&.active {
color: #5147ff;
font-weight: 600;
}
}
}
}
.list-scroll-view {
flex: 1;
height: 0;
}
.list-content {
margin: 24rpx;
background: #ffffff;
border-radius: 24rpx;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1rpx solid #e2e8f0;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.015);
}
.record-card {
background: #ffffff;
padding: 32rpx 32rpx;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
border-bottom: 1rpx solid #f1f5f9;
&:last-child {
border-bottom: none;
}
.card-left {
flex: 1;
display: flex;
flex-direction: column;
margin-right: 20rpx;
.record-name {
font-size: 28rpx;
font-weight: 600;
color: #0f172a;
margin-bottom: 8rpx;
line-height: 1.4;
}
.record-time {
font-size: 22rpx;
color: #94a3b8;
margin-bottom: 12rpx;
}
.badge-container {
display: flex;
flex-direction: row;
}
.record-badge {
display: flex;
flex-direction: row;
align-items: center;
padding: 4rpx 14rpx;
border-radius: 8rpx;
&.badge-increase {
background-color: rgba(16, 185, 129, 0.08);
}
&.badge-decrease {
background-color: rgba(239, 68, 68, 0.08);
}
.badge-text {
font-size: 20rpx;
font-weight: 500;
&.text-increase {
color: #10b981;
}
&.text-decrease {
color: #ef4444;
}
}
}
}
.card-right {
display: flex;
flex-direction: column;
align-items: flex-end;
.record-amount {
font-size: 34rpx;
font-weight: 700;
margin-bottom: 8rpx;
line-height: 1.2;
&.amount-increase {
color: #10b981;
}
&.amount-decrease {
color: #ef4444;
}
}
.record-balance {
font-size: 22rpx;
color: #94a3b8;
}
}
}
.loading-state, .no-more-state {
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx 0;
.loading-text, .no-more-text {
font-size: 24rpx;
color: #94a3b8;
}
}
.empty-wrapper {
padding-top: 160rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
}

View File

@@ -0,0 +1,319 @@
<template>
<view class="container page-container">
<!-- 导航栏 -->
<custom-nav-bar :title="fileName" show-back>
<template v-slot:right>
<!-- #ifdef H5 || WEB -->
<text
class="iconfont icon-ShareAltOutlined font-48"
@click="handleShare"
></text>
<!-- #endif -->
</template>
</custom-nav-bar>
<view class="file-preview-content">
<!-- 加载中 -->
<!-- <template v-if="isLoading">
<view class="loading-box">
<image
class="icon-loading-image"
src="@/static/assets/icon_loading.svg"
mode="widthFix"
/>
</view>
</template> -->
<!-- Web-View 预览 -->
<template v-if="previewUrl">
<!-- #ifdef H5 || WEB -->
<iframe
class="w-full h-full"
frameborder="0"
:src="previewUrl"
></iframe>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<web-view
class="w-full h-full"
:src="previewUrl"
id="webview"
></web-view>
<!-- #endif -->
</template>
</view>
</view>
</template>
<script setup lang="uts">
import { onAddToFavorites } from "@dcloudio/uni-app";
import {
parseUrlInfo,
getFileName,
} from "@/subpackages/utils/parseUrlInfo.uts";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
// 请求工具
import {
apiUserTicketCreate,
apiAgentConversationShare,
} from "@/servers/agentDev.uts";
import { SUCCESS_CODE } from "@/constants/codes.constants";
// 环境配置
import { API_BASE_URL } from "@/constants/config";
const previewUrl = ref<string>(""); // 预览地址
const fileName = ref<string>(""); // 文件名
const pageTitle = ref<string>(""); // 页面标题
const fileType = ref<string>(""); // 文件类型
const isLoading = ref<boolean>(true); // 是否加载中
const conversationId = ref<string>(null); // 会话ID
const fileProxyUrl = ref<string>(""); // 文件路径
const sk = ref<string>(""); // sk 微信小程序中需要 sk 查询参数
const isDev = process.env.NODE_ENV === "development";
let baseUrl = "";
// #ifdef H5 || WEB
baseUrl = isDev ? API_BASE_URL : window?.location?.origin;
// #endif
// #ifdef MP-WEIXIN
baseUrl = API_BASE_URL;
// #endif
/**
* 获取 shareKey
*/
const getConversationShareKey = async (
conversationId: string,
content: string = "",
): Promise<string> => {
const params = {
conversationId,
type: "CONVERSATION",
content,
};
const { data, code } = await apiAgentConversationShare(params);
if (code !== SUCCESS_CODE || !data?.shareKey) {
throw new Error(t("Mobile.FilePreview.getShareKeyFailed"));
}
return data.shareKey;
};
// 分享文件
const handleShare = async () => {
if (!baseUrl) {
uni.showToast({
title: t("Mobile.FilePreview.shareDomainMissing"),
icon: "none",
duration: 2000,
});
return;
}
try {
const shareKey = await getConversationShareKey(
conversationId.value,
fileProxyUrl.value || "",
);
const previewUrlValue = `${baseUrl}/static/file-preview.html?sk=${shareKey}`;
// 复制到剪切板
uni.setClipboardData({
data: previewUrlValue,
success: () => {
uni.showToast({
title: t("Mobile.FilePreview.shareSuccess"),
icon: "none",
duration: 2000,
});
},
fail: () => {
uni.showToast({
title: t("Mobile.FilePreview.shareFailed"),
icon: "none",
duration: 2000,
});
},
});
} catch (error) {
console.error("分享失败:", error);
uni.showToast({
title: t("Mobile.FilePreview.shareFailed"),
icon: "none",
duration: 2000,
});
}
};
// 获取 ticket
const getTicket = async () => {
try {
const { code, data } = await apiUserTicketCreate();
return code === SUCCESS_CODE && data ? data : "";
} catch (e) {
console.log(e);
return "";
}
};
// 设置 html 文件标题
const setHtmlTitle = (url: string) => {
uni.request({
url,
method: "GET",
success: (res) => {
const data = res.data;
// 使用正则表达式提取 <title>...</title> 中的内容
const match = data.match(/<title[^>]*>([^<]+)<\/title>/i);
if (match && match[1]) {
const title = match[1].trim();
// 赋值给 pageTitle
pageTitle.value = title;
// 更新导航栏标题
uni.setNavigationBarTitle({
title: title,
});
}
},
fail: (err) => {
console.error("获取 html 失败", err);
},
});
};
onLoad(async (options: { cId: number; fileProxyUrl: string }) => {
let { cId, fileProxyUrl: fileProxyUrlValue } = options;
if (!cId || !fileProxyUrlValue) {
uni.showToast({
title: t("Mobile.FilePreview.invalidParams"),
icon: "none",
});
isLoading.value = false;
return;
}
const { path, query } = parseUrlInfo(decodeURIComponent(fileProxyUrlValue));
fileProxyUrl.value = path;
// 得到文件名
const fileNameValue = getFileName(path);
conversationId.value = cId;
fileName.value = fileNameValue;
uni.setNavigationBarTitle({ title: fileNameValue });
// 得到文件类型
const fileTypeValue = fileNameValue.split(".").pop();
fileType.value = fileTypeValue;
try {
if (query?.sk) {
// 有 sk 参数,直接使用
sk.value = query.sk;
previewUrl.value = `${baseUrl}/static/file-preview.html?sk=${query.sk}`;
// 设置 html 文件标题
setHtmlTitle(baseUrl + fileProxyUrl.value + "?sk=" + query.sk);
} else {
// 没有 sk 参数,需要获取 shareKey 和 _ticket
// 1. 获取 shareKey 用于后续分享功能
let shareKey = await getConversationShareKey(cId, path);
sk.value = shareKey;
const fileUrl = encodeURIComponent(`${fileProxyUrl.value}`);
// #ifdef H5 || WEB
if (isDev) {
// _sk 用于下载功能_ticket 用于当前页面访问_ticket 只能消费一次,适合当前用户自己访问)
const _ticket = await getTicket();
previewUrl.value = `${baseUrl}/static/file-preview.html?fileUrl=${baseUrl + fileUrl}&_ticket=${_ticket}&_sk=${shareKey}`;
} else {
previewUrl.value = `${baseUrl}/static/file-preview.html?fileUrl=${baseUrl + fileUrl}&sk=${shareKey}`;
}
// #endif
// #ifdef MP-WEIXIN
// _sk 用于下载功能_ticket 用于当前页面访问_ticket 只能消费一次,适合当前用户自己访问)
const _ticket = await getTicket();
previewUrl.value = `${baseUrl}/static/file-preview.html?fileUrl=${baseUrl + fileUrl}&_ticket=${_ticket}&_sk=${shareKey}`;
// 设置 html 文件标题
setHtmlTitle(baseUrl + fileProxyUrl.value + "?sk=" + shareKey);
// #endif
}
} catch (error) {
console.error("构建预览URL失败:", error);
} finally {
isLoading.value = false;
}
});
// #ifdef MP-WEIXIN
// 转发给朋友
// 分享到首页,由首页自动跳转到文件预览页面,解决分享后无返回按钮问题
onShareAppMessage(() => {
return {
title: pageTitle.value || fileName.value || "",
path:
"/pages/index/index?cId=" +
conversationId.value +
"&fileProxyUrl=" +
encodeURIComponent(`${fileProxyUrl.value}?sk=${sk.value}`),
};
});
// 收藏
onAddToFavorites(() => {
return {
title: pageTitle.value || fileName.value || "",
path:
"/pages/index/index?cId=" +
conversationId.value +
"&fileProxyUrl=" +
encodeURIComponent(`${fileProxyUrl.value}?sk=${sk.value}`),
};
});
// #endif
</script>
<style scoped lang="scss">
.container {
height: 100%;
display: flex;
flex-direction: column;
.file-preview-content {
flex: 1;
overflow: hidden;
}
.loading-box {
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;
}
}
.text-gray-500 {
color: #999;
}
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<login-layout
:title="tenantConfigInfo?.siteName"
:logo="tenantConfigInfo?.siteLogo"
:show-back="false"
:show-nav-bar="true"
form-content-height="800rpx"
>
<view class="container-weixin">
<view class="sub-title-wrapper">
<text class="sub-title">
{{
t("Mobile.Auth.welcomeUse", {
siteName: tenantConfigInfo?.siteName || "",
})
}}
</text>
</view>
<!-- 底部登录区域 -->
<view class="login-section">
<!-- 协议勾选 -->
<agreement-checkbox v-model="isAgreed" />
<!-- 登录按钮 -->
<button
class="login-btn"
:open-type="isAgreed ? 'getPhoneNumber' : ''"
@getphonenumber="onGetPhoneNumber"
@tap="onLoginClick"
>
{{ t("Mobile.Auth.oneClickLogin") }}
</button>
</view>
<!-- 手机号登录链接 -->
<view class="phone-login-link" @tap="goToPhoneLogin">
{{ t("Mobile.Auth.phoneLoginRegister") }}
</view>
</view>
</login-layout>
</template>
<script setup lang="uts">
import { apiWechatLogin } from "@/servers/account";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import { redirectTo } from "@/utils/common.uts";
import { ref } from "vue";
import { ACCESS_TOKEN } from "@/constants/home.constants";
import AgreementCheckbox from "@/components/agreement-checkbox/agreement-checkbox.uvue";
import { apiTenantConfig } from "@/servers/account";
import type { TenantConfigInfo } from "@/types/interfaces/login";
import LoginLayout from "@/subpackages/pages/login/components/login-layout/login-layout.uvue";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
const redirectUrl = ref("");
const isAgreed = ref(false);
const tenantConfigInfo = ref<TenantConfigInfo>(null);
function onLoginClick() {
if (!isAgreed.value) {
uni.showModal({
title: t("Mobile.Common.tip"),
content: t("Mobile.Auth.pleaseAgreeProtocol"),
confirmText: t("Mobile.Common.agree"),
cancelText: t("Mobile.Common.disagree"),
success: function (res) {
if (res.confirm) {
isAgreed.value = true;
}
},
});
}
}
function goToPhoneLogin() {
const url = `/subpackages/pages/login/login?redirect=${encodeURIComponent(redirectUrl.value)}`;
uni.navigateTo({ url });
}
async function onGetPhoneNumber(event: Any) {
// 1. 检查用户是否取消授权
if (!event?.detail || event.detail.errMsg !== "getPhoneNumber:ok") {
uni.showToast({ title: t("Mobile.Auth.userCancelAuthorize"), icon: "none" });
return;
}
// 2. 微信新版返回的 phoneCode
const phoneCode = event.detail.code;
try {
uni.showLoading({ title: t("Mobile.Page.loading") });
// 3. 请求后端登录接口
const { code, data, message } = await apiWechatLogin({ code: phoneCode });
handleLoginSuccess(code, data, message);
} finally {
uni.hideLoading();
}
}
function handleLoginSuccess(code: string, data: any, message: string) {
// 4. 登录成功
if (code === SUCCESS_CODE) {
uni.setStorageSync(ACCESS_TOKEN, data.token);
uni.showToast({
title: t("Mobile.Auth.loginSuccess"),
icon: "success",
});
setTimeout(() => {
redirectTo(redirectUrl.value);
}, 1000);
} else {
uni.showToast({
title: message ?? t("Mobile.Auth.loginFailed"),
icon: "none",
});
}
}
// 获取用户配置
const fetchTenantConfig = async () => {
const { code, data } = await apiTenantConfig();
if (code === SUCCESS_CODE) {
tenantConfigInfo.value = data;
}
};
onMounted(() => {
fetchTenantConfig();
});
onLoad((query) => {
// 登录成功后跳转的页面
if (query.redirect) {
redirectUrl.value = decodeURIComponent(query.redirect);
console.log("登录成功后跳转的页面:", redirectUrl.value);
}
});
</script>
<style lang="scss" scoped>
.container-weixin {
height: 100%;
display: flex;
flex-direction: column;
.sub-title-wrapper {
margin-top: 120rpx;
margin-bottom: 48rpx;
text-align: center;
flex: 1;
.sub-title {
color: #000;
text-align: center;
font-size: 40rpx;
font-style: normal;
font-weight: 600;
line-height: 56rpx;
}
}
.login-section {
margin-bottom: 180rpx;
.login-btn {
margin-top: 20rpx;
width: 100%;
border-radius: 16rpx;
padding: 25rpx 24rpx;
font-size: 32rpx;
font-weight: 400;
margin-bottom: 32rpx;
line-height: 48rpx;
text-align: center;
background: #5147ff;
color: #ffffff;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
}
.phone-login-link {
width: 100%;
text-align: center;
font-size: 32rpx;
color: rgba(21, 23, 31, 0.5);
margin-top: 20rpx;
}
}
</style>

View File

@@ -0,0 +1,450 @@
<template>
<view class="captcha-verify">
<view class="captcha-verify-header">
<!-- 返回按钮 -->
<view class="back-box" @tap="handleBack">
<text class="iconfont icon-a-Chevronleft"></text>
</view>
</view>
<view class="captcha-verify-content">
<view class="title">
{{
phoneOrEmail?.includes("@")
? t("Mobile.Auth.inputEmailCode")
: t("Mobile.Auth.inputSmsCode")
}}
</view>
<view class="phone-info">
{{ t("Mobile.Auth.codeSentToPrefix") }}
{{
phoneOrEmail?.includes("@")
? `${t("Mobile.Auth.codeSentToEmailTarget")} `
: `${t("Mobile.Auth.codeSentToPhoneTarget")} `
}}
{{ `${!phoneOrEmail?.includes("@") ? "86" : ""} ${phoneOrEmail}` }}
</view>
<view class="captcha-input-container">
<input
ref="captchaInputRef"
class="captcha-input"
:class="{ active: isFocused }"
type="number"
pattern="[0-9]*"
inputmode="numeric"
maxlength="6"
:value="captchaCode"
:focus="isFocused"
:adjust-position="false"
:hold-keyboard="true"
cursor-spacing="0"
:placeholder="t('Mobile.Auth.inputVerifyCode')"
cursor-color="#5147FF"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@paste="handlePaste"
@longpress="handleLongPress"
/>
</view>
<view class="resend-container">
<text class="countdown" v-if="countdown > 0">{{
t("Mobile.Auth.resendAfterSeconds", { seconds: countdown })
}}</text>
<text
class="resend-btn"
:class="{ disabled: countdown > 0 }"
@tap="handleResend"
>{{ t("Mobile.Auth.resend") }}</text
>
</view>
</view>
</view>
</template>
<script lang="uts" setup>
import { apiSendCode } from "@/servers/account";
import { SUCCESS_CODE, AGENT_NOT_EXIST } from "@/constants/codes.constants";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
// 定义组件props
interface Props {
phoneOrEmail?: string;
tenantConfigInfo?: TenantConfigInfo;
}
const props = withDefaults(defineProps<Props>(), {
phoneOrEmail: "",
tenantConfigInfo: null,
});
const emit = defineEmits(["verify-login", "back"]);
const captchaCode = ref<string>("");
const countdown = ref<number>(59);
let countdownTimer: number | null = null;
const captchaInputRef = ref<any>(null);
const isFocused = ref<boolean>(false);
// 判断配置信息是否邮箱登录
const isEmailAuth = computed(() => {
if (!props.tenantConfigInfo) {
return false;
}
return props.tenantConfigInfo?.authType === 3;
});
onMounted(() => {
open();
});
const open = () => {
captchaCode.value = "";
startCountdown();
nextTick(() => {
focusInput();
});
};
const handleBack = () => {
clearCountdown();
emit("back");
};
const handleInput = async (event: any) => {
let value = event.detail.value || "";
// 强制过滤非数字字符
value = value.replace(/[^0-9]/g, "");
// 限制最大长度为6位
if (value.length > 6) {
value = value.slice(0, 6);
}
captchaCode.value = value;
// 强制更新输入框显示值
// #ifdef MP-WEIXIN
if (captchaInputRef.value) {
captchaInputRef.value.value = value;
}
// #endif
// 检查是否输入完成6位数字
if (value.length === 6 && /^\d{6}$/.test(value)) {
emit("verify-login", value);
}
};
const handleFocus = () => {
isFocused.value = true;
};
const handleBlur = () => {
isFocused.value = false;
};
const handlePaste = async (event: any) => {
// #ifdef H5
// H5平台使用 clipboardData
event.preventDefault();
let pasteData = "";
if (event.clipboardData && event.clipboardData.getData) {
pasteData = event.clipboardData.getData("text");
await processPasteData(pasteData);
}
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
// 微信小程序和App平台在用户点击粘贴后触发此事件
// 延迟一下确保粘贴操作完成后再读取
setTimeout(async () => {
await getClipboardAndPaste();
}, 100);
// #endif
};
const getClipboardAndPaste = async () => {
try {
const res = await uni.getClipboardData();
await processPasteData(res.data);
} catch (error) {
uni.showToast({
title: t("Mobile.Auth.clipboardGetFailed"),
icon: "none",
duration: 2000,
});
}
};
const processPasteData = async (pasteData: string) => {
// 验证粘贴内容是否为6位数字
if (pasteData && /^\d{6}$/.test(pasteData)) {
// 直接设置验证码
captchaCode.value = pasteData;
// 强制更新输入框显示值
// #ifdef MP-WEIXIN
if (captchaInputRef.value) {
captchaInputRef.value.value = pasteData;
}
// #endif
// 触发验证
emit("verify-login", pasteData);
// 提示用户
uni.showToast({
title: t("Mobile.Auth.codeAutoFilled"),
icon: "success",
duration: 1500,
});
} else {
// 粘贴内容不是6位数字提示用户
uni.showToast({
title: t("Mobile.Auth.pasteSixDigits"),
icon: "none",
duration: 2000,
});
}
};
const handleLongPress = async (event: any) => {
// #ifdef MP-WEIXIN || APP-PLUS
// 长按时直接读取剪贴板并询问是否填充
try {
const res = await uni.getClipboardData();
const clipData = res.data;
// 检查剪贴板是否包含6位数字验证码
if (clipData && /^\d{6}$/.test(clipData)) {
// 直接填充,不询问
await processPasteData(clipData);
} else if (clipData) {
// 剪贴板有内容但不是6位数字
uni.showToast({
title: t("Mobile.Auth.clipboardNotSixDigits"),
icon: "none",
duration: 2000,
});
} else {
uni.showToast({
title: t("Mobile.Auth.clipboardEmpty"),
icon: "none",
duration: 1500,
});
}
} catch (error) {
uni.showToast({
title: t("Mobile.Auth.clipboardReadFailed"),
icon: "none",
duration: 2000,
});
}
// #endif
};
const startCountdown = () => {
countdown.value = 59;
countdownTimer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearCountdown();
}
}, 1000);
};
const clearCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
};
// 兼容微信小程序和H5的聚焦方法
const focusInput = () => {
// #ifdef MP-WEIXIN
// 微信小程序使用 isFocused 响应式变量控制聚焦
isFocused.value = true;
// #endif
// #ifdef H5
// H5平台使用 ref 引用
if (captchaInputRef.value && captchaInputRef.value.focus) {
captchaInputRef.value.focus();
}
// #endif
// #ifdef APP-PLUS
// App平台使用 ref 引用
if (captchaInputRef.value && captchaInputRef.value.focus) {
captchaInputRef.value.focus();
}
// #endif
};
const handleResend = async () => {
if (countdown.value > 0) return;
const params = {
type: "LOGIN_OR_REGISTER",
// "phone": props.phoneOrEmail
};
if (isEmailAuth.value) {
params.email = props.phoneOrEmail;
} else {
params.phone = props.phoneOrEmail;
}
try {
uni.showLoading({
title: t("Mobile.Auth.sending"),
mask: false,
});
// 发送验证码
const { code, data, message } = await apiSendCode(params);
if (isEmailAuth.value) {
// 邮箱验证码
if (code === SUCCESS_CODE) {
startCountdown();
uni.showToast({
title: t("Mobile.Auth.resendSuccess"),
icon: "success",
});
} else if (code === AGENT_NOT_EXIST) {
uni.showToast({
title: message,
icon: "none",
duration: 5000,
});
}
} else {
// 手机验证码
if (code === SUCCESS_CODE) {
startCountdown();
uni.showToast({
title: t("Mobile.Auth.resendSuccess"),
icon: "success",
});
} else {
// 手机验证失败提示消息
uni.showToast({
title: message,
icon: "none",
});
}
}
} finally {
uni.hideLoading();
}
};
onUnmounted(() => {
clearCountdown();
});
</script>
<style lang="scss" scoped>
.captcha-verify {
overflow: auto;
display: flex;
flex-direction: column;
.captcha-verify-header {
.back-box {
display: inline-block;
width: 58rpx;
height: 58rpx;
line-height: 58rpx;
text-align: center;
background-color: rgba(12, 20, 102, 0.04);
border-radius: 50%;
.iconfont {
font-size: 40rpx;
line-height: 58rpx;
color: #333;
}
}
}
.captcha-verify-content {
flex: 1;
overflow: auto;
.title {
font-size: 40rpx;
line-height: 56rpx;
font-weight: 600;
color: rgba(21, 23, 31, 1);
margin-bottom: 24rpx;
line-height: 64rpx;
text-align: center;
}
.phone-info {
font-size: 28rpx;
font-weight: 400;
color: rgba(21, 23, 31, 0.7);
margin-bottom: 80rpx;
line-height: 44rpx;
text-align: center;
}
.captcha-input-container {
display: flex;
justify-content: center;
margin-bottom: 80rpx;
.captcha-input {
width: 100%;
// max-width: 600rpx;
height: 88rpx;
border: 2rpx solid transparent;
border-radius: 16rpx;
text-align: left;
font-size: 32rpx;
font-weight: 400;
color: rgba(21, 23, 31, 1);
background-color: rgba(12, 20, 102, 0.04);
padding: 0 24rpx;
// letter-spacing: 8rpx;
&.active {
border-color: rgba(81, 71, 255, 1);
}
&:focus {
border-color: rgba(81, 71, 255, 1);
background-color: #fff;
outline: none;
}
}
}
.resend-container {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 16rpx;
font-size: 28rpx;
font-weight: 400;
line-height: 44rpx;
color: rgba(21, 23, 31, 0.7);
.countdown {
}
.resend-btn {
cursor: pointer;
color: #5147ff;
&.disabled {
color: #ccc;
cursor: not-allowed;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,643 @@
<template>
<view class="container">
<!-- 分段器组件 -->
<segmented-control
:options="loginOptions"
v-model="currentLoginType"
@change="handleLoginTypeChange"
class="login-segmented"
/>
<view class="sub-title-wrapper">
<text class="sub-title">{{
t("Mobile.Auth.welcomeUse", {
siteName: tenantConfigInfo?.siteName || "",
})
}}</text>
</view>
<view class="phone-input-wrapper" v-if="!isEmailAuth">
<view class="country-code">
<text>{{ t("Mobile.Auth.defaultAreaCode") }}</text>
<uni-icon class="iconfont icon-a-Chevrondown" />
</view>
<input
class="phone-input"
type="number"
:placeholder="t('Mobile.Auth.inputPhone')"
v-model="phoneNumber"
cursor-color="#5147FF"
/>
<view class="clear-btn" v-if="phoneNumber" @click="clearPhoneNumber">
<uni-icon class="iconfont icon-a-Xcircle-fill clear-icon" />
</view>
</view>
<view v-else class="phone-input-wrapper">
<input
class="phone-input"
type="email"
:placeholder="t('Mobile.Auth.inputEmail')"
v-model="emailNumber"
cursor-color="#5147FF"
/>
<view class="clear-btn" v-if="emailNumber" @click="clearEmailNumber">
<uni-icon class="iconfont icon-a-Xcircle-fill clear-icon" />
</view>
</view>
<view class="password-input-wrapper" v-if="!isCaptchaVerify">
<input
class="password-input"
:password="!showPassword"
:placeholder="t('Mobile.Auth.inputPassword')"
v-model="password"
cursor-color="#5147FF"
/>
<!-- <view class="eye-btn" @click="togglePasswordVisibility">
<text class="eye-icon">{{ showPassword ? '👁' : '🙈' }}</text>
</view> -->
<view class="clear-btn" v-if="password" @click="clearPassword">
<uni-icon class="iconfont icon-a-Xcircle-fill clear-icon" />
</view>
</view>
<button
class="login-btn"
@click="onFinish"
:loading="loading"
:disabled="loading"
>
{{
isCaptchaVerify
? t("Mobile.Auth.nextStep")
: t("Mobile.Auth.login")
}}
</button>
<agreement-checkbox v-model="agreeTerms" />
<!-- #ifdef H5 || WEB-->
<button :id="buttonId" type="button" class="captcha-button" />
<aliyun-captcha-h5
:config="tenantConfigInfo"
:element-id="buttonId"
@doAction="handlerSuccess"
/>
<!-- #endif -->
</view>
</template>
<script setup lang="uts">
import { ref } from "vue";
import { apiLogin } from "@/servers/account";
import type { ILoginResult, LoginFieldType } from "@/types/interfaces/login";
import { SUCCESS_CODE, AGENT_NOT_EXIST } from "@/constants/codes.constants";
import { ACCESS_TOKEN, EXPIRE_DATE } from "@/constants/home.constants";
import { apiSendCode } from "@/servers/account";
import type { TenantConfigInfo } from "@/types/interfaces/login";
import SegmentedControl from "@/components/segmented-control/segmented-control.uvue";
import AgreementCheckbox from "@/components/agreement-checkbox/agreement-checkbox.uvue";
import { redirectTo } from "@/utils/common.uts";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
// 定义组件props
interface Props {
tenantConfigInfo?: TenantConfigInfo;
redirectUrl?: string;
isPopup?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
tenantConfigInfo: null,
redirectUrl: "",
isPopup: false,
});
const emit = defineEmits(["show-verify", "login-success"]);
const phoneNumber = ref("");
const emailNumber = ref("");
const password = ref("");
const agreeTerms = ref(false);
const showPassword = ref(false);
const captchaVerifyParam = ref(""); // 验证码
const isCaptchaVerify = ref(false); // 是否验证码
const loading = ref(false);
// 阿里云验证码按钮id
const buttonId = ref<string>("aliyun-captcha-id");
// 分段器相关数据
const currentLoginType = ref("password");
const loginOptions = computed(() => [
{ label: t("Mobile.Auth.passwordLogin"), value: "password" },
{ label: t("Mobile.Auth.codeLoginRegister"), value: "phone" },
]);
// 处理登录方式切换
const handleLoginTypeChange = (value: string | number, index: number) => {
currentLoginType.value = value as string;
// 根据选择的登录方式更新状态
switch (value) {
case "password":
isCaptchaVerify.value = false;
break;
case "phone":
isCaptchaVerify.value = true;
break;
}
};
// 判断配置信息是否邮箱登录
const isEmailAuth = computed(() => {
if (!props.tenantConfigInfo) {
return false;
}
return props.tenantConfigInfo?.authType === 3;
});
const onFinish = () => {
if (!agreeTerms.value) {
uni.showModal({
title: t("Mobile.Common.tip"),
content: t("Mobile.Auth.pleaseAgreeProtocol"),
confirmText: t("Mobile.Common.agree"),
cancelText: t("Mobile.Common.disagree"),
success: function (res) {
if (res.confirm) {
agreeTerms.value = true;
doLogin();
}
},
});
} else {
doLogin();
}
};
const doLogin = () => {
// 阿里云验证码
const tenantConfigInfo = props?.tenantConfigInfo;
const { captchaSceneId, captchaPrefix, openCaptcha } =
tenantConfigInfo || {};
// 只有同时满足三个条件才启用验证码场景ID存在、身份标存在、开启验证码
const needAliyunCaptcha = !!(
tenantConfigInfo &&
captchaSceneId !== "" &&
captchaPrefix !== "" &&
openCaptcha
);
// 如果需要阿里云验证码,则点击按钮触发验证码
if (needAliyunCaptcha) {
// #ifdef H5 || WEB
// H5和WEB端验证码
document?.getElementById(buttonId.value)?.click();
// #endif
// #ifdef MP-WEIXIN
// 微信小程序直接执行登录/验证码逻辑
handleClickAliyunCaptcha();
// #endif
} else {
//不需要阿里云验证码,直接执行登录/验证码逻辑
handlerSuccess();
}
};
const handleClickAliyunCaptcha = () => {
uni.navigateTo({
url: "/subpackages/pages/aliyun-captcha/aliyun-captcha",
events: {
getCaptchaVerifyParam: (captchaVerifyParam) => {
// 定义一个getCaptchaVerifyParam事件获取二次验证参数captchaVerifyParam
console.log("验证码返回结果:", captchaVerifyParam);
// 发起二次校验
handlerSuccess(captchaVerifyParam);
},
},
});
};
const handlerSuccess = (captchaVerifyParam: string = "") => {
if (isCaptchaVerify.value) {
// 验证码登录
handleNext(captchaVerifyParam);
} else {
// 密码登录
handleLogin(captchaVerifyParam);
}
};
const handleLogin = (captchaVerifyParam: string = "") => {
if (isEmailAuth.value) {
handleLoginByEmail(captchaVerifyParam);
} else {
handleLoginByPhone(captchaVerifyParam);
}
};
// 校验登录参数
const validateLoginParams = () => {
if (isEmailAuth.value) {
// 校验邮箱是否为空
if (!emailNumber.value) {
uni.showToast({
title: t("Mobile.Auth.inputEmail"),
icon: "none",
});
return false;
}
// 校验邮箱格式
const emailRegex =
/^[a-zA-Z0-9_.-]+@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z0-9]{2,6}$/;
if (!emailRegex.test(emailNumber.value)) {
uni.showToast({
title: t("Mobile.Auth.invalidEmailFormat"),
icon: "none",
});
return false;
}
} else {
// 校验手机号是否为空
if (!phoneNumber.value) {
uni.showToast({
title: t("Mobile.Auth.inputPhone"),
icon: "none",
});
return false;
}
// 校验手机号格式
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(phoneNumber.value)) {
uni.showToast({
title: t("Mobile.Auth.invalidPhoneFormat"),
icon: "none",
});
return false;
}
}
return true;
};
// 下一步
const handleNext = async (captchaVerifyParam: string = "") => {
// 校验登录参数
if (!validateLoginParams()) {
return;
}
const params = {
type: "LOGIN_OR_REGISTER",
captchaVerifyParam,
// "phone": phoneNumber.value
};
if (isEmailAuth.value) {
params.email = emailNumber.value;
} else {
params.phone = phoneNumber.value;
}
const phoneOrEmail = isEmailAuth.value
? emailNumber.value
: phoneNumber.value;
// 发送验证码
try {
loading.value = true;
const { code, data, message } = await apiSendCode(params);
if (isEmailAuth.value) {
// 邮箱验证码
if (code === SUCCESS_CODE) {
// 系统未配置邮件服务请直接输出本次验证码779895
uni.showToast({
title: t("Mobile.Auth.codeSentSuccess"),
icon: "none",
});
emit("show-verify", phoneOrEmail, captchaVerifyParam);
} else if (code === AGENT_NOT_EXIST) {
uni.showToast({
title: message,
icon: "none",
duration: 5000,
});
emit("show-verify", phoneOrEmail, captchaVerifyParam);
}
} else {
// 手机验证码
if (code === SUCCESS_CODE) {
uni.showToast({
title: t("Mobile.Auth.codeSentSuccess"),
icon: "none",
});
emit("show-verify", phoneOrEmail, captchaVerifyParam);
} else {
// 手机验证失败需要将 message 消息提示出来
uni.showToast({
title: message,
icon: "none",
});
}
}
} finally {
loading.value = false;
}
};
// 手机号登录
const handleLoginByPhone = async (captchaVerifyParam: string = "") => {
// 校验登录参数
if (!validateLoginParams()) {
return;
}
if (!password.value) {
uni.showToast({
title: t("Mobile.Auth.inputPassword"),
icon: "none",
});
return;
}
// 执行登录逻辑
const params: LoginFieldType = {
phoneOrEmail: phoneNumber.value,
areaCode: "86",
password: password.value,
captchaVerifyParam,
};
try {
loading.value = true;
const result = await apiLogin(params);
handleLoginSuccess(result);
} finally {
loading.value = false;
}
};
// 邮箱登录
const handleLoginByEmail = async (captchaVerifyParam: string = "") => {
// 校验登录参数
if (!validateLoginParams()) {
return;
}
if (!password.value) {
uni.showToast({
title: t("Mobile.Auth.inputPassword"),
icon: "none",
});
return;
}
// 执行登录逻辑
const params: LoginFieldType = {
phoneOrEmail: emailNumber.value,
areaCode: "",
password: password.value,
captchaVerifyParam,
};
const { code, data, message } = await apiLogin(params);
if (code === SUCCESS_CODE) {
// 邮箱登录
uni.showToast({
title: t("Mobile.Auth.loginSuccess"),
icon: "success",
});
setTimeout(() => {
// #ifdef H5 || WEB
if (process.env.NODE_ENV === "development") {
uni.setStorageSync(ACCESS_TOKEN, data.token);
}
// #endif
// #ifdef MP-WEIXIN
uni.setStorageSync(ACCESS_TOKEN, data.token);
// #endif
if (props.isPopup) {
emit("login-success");
} else {
redirectTo(props.redirectUrl);
}
}, 1000);
} else if (code === AGENT_NOT_EXIST) {
uni.showToast({
title: message,
icon: "none",
});
}
};
// 登录成功后
const handleLoginSuccess = (result: ILoginResult) => {
const { code, data, message } = result;
if (code === SUCCESS_CODE) {
// 手机号登录
uni.showToast({
title: t("Mobile.Auth.loginSuccess"),
icon: "success",
});
setTimeout(() => {
// #ifdef H5 || WEB
if (process.env.NODE_ENV === "development") {
uni.setStorageSync(ACCESS_TOKEN, data.token);
}
// #endif
// #ifdef MP-WEIXIN
uni.setStorageSync(ACCESS_TOKEN, data.token);
// #endif
if (props.isPopup) {
emit("login-success");
} else {
redirectTo(props.redirectUrl);
}
}, 1000);
} else if (code === AGENT_NOT_EXIST) {
uni.showToast({
title: message,
icon: "none",
});
}
};
const clearPhoneNumber = () => {
phoneNumber.value = "";
};
const clearEmailNumber = () => {
emailNumber.value = "";
};
const clearPassword = () => {
password.value = "";
};
const togglePasswordVisibility = () => {
showPassword.value = !showPassword.value;
};
</script>
<style lang="scss" scoped>
.container {
// .login-segmented {
// margin-bottom: 80rpx;
// }
.sub-title-wrapper {
margin-top: 80rpx;
margin-bottom: 48rpx;
.sub-title {
color: #000;
text-align: center;
font-size: 40rpx;
font-style: normal;
font-weight: 600;
line-height: 56rpx;
}
}
.clear-btn {
position: absolute;
right: 32rpx;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
.clear-icon {
color: #b2b4b8;
font-size: 32rpx;
}
}
.phone-input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
background: rgba(12, 20, 102, 0.04);
border-radius: 16rpx;
padding: 24rpx 32rpx;
margin-bottom: 36rpx;
position: relative;
border: 2rpx solid transparent;
transition: border-color 0.3s ease;
&:focus-within {
border-color: #5147ff;
}
.country-code {
color: rgba(21, 23, 31, 1);
margin-right: 24rpx;
border-right: 2rpx solid rgba(12, 20, 102, 0.12);
padding-right: 24rpx;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 10rpx;
text {
margin-left: 10rpx;
font-size: 32rpx;
font-weight: 400;
line-height: 48rpx;
}
.iconfont {
font-size: 28rpx;
}
}
.phone-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 32rpx;
color: #15171f;
font-weight: 400;
line-height: 48rpx;
padding-right: 50rpx;
caret-color: #5147ff;
cursor-color: #5147ff;
}
}
.password-input-wrapper {
background: rgba(12, 20, 102, 0.04);
border-radius: 16rpx;
padding: 24rpx 32rpx;
margin-bottom: 36rpx;
position: relative;
border: 2rpx solid transparent;
transition: border-color 0.3s ease;
&:focus-within {
border-color: #5147ff;
}
.password-input {
width: 100%;
background: transparent;
border: none;
outline: none;
font-size: 32rpx;
color: #15171f;
font-weight: 400;
line-height: 48rpx;
padding-right: 60rpx;
caret-color: #5147ff;
cursor-color: #5147ff;
}
.eye-btn {
position: absolute;
right: 80rpx;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
.eye-icon {
font-size: 32rpx;
color: #b2b4b8;
}
}
}
.login-btn {
margin-top: 20rpx;
width: 100%;
border-radius: 16rpx;
padding: 25rpx 24rpx;
font-size: 32rpx;
font-weight: 400;
margin-bottom: 32rpx;
line-height: 48rpx;
text-align: center;
background: #5147ff;
color: #ffffff;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.captcha-button {
position: absolute;
left: 10px;
top: 10px;
visibility: hidden;
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<view class="login-lang-wrapper" v-if="langList.length > 1">
<view class="login-lang-switcher" @tap="handleOpen">
<text class="iconfont icon-Globe"></text>
<text class="lang-text">{{ currentLanguageName }}</text>
<text class="iconfont icon-a-Chevrondown"></text>
</view>
</view>
<radio-list-drawer
:visible="visible"
:title="t('Mobile.Profile.selectLanguage')"
:list="languageOptions"
:current-value="localCurrentLang"
@onClose="handleClose"
@onChange="handleLanguageChange"
/>
</template>
<script setup lang="uts">
import { useI18n, normalizeLang } from "@/utils/i18n";
import { RadioListItem } from "@/types/interfaces/radio";
import RadioListDrawer from "@/components/radio-list-drawer/radio-list-drawer.uvue";
import { ref, computed } from "vue";
const { t, currentLang, langList, setLanguage } = useI18n();
const visible = ref(false);
const localCurrentLang = ref("");
const languageOptions = computed((): RadioListItem[] => {
return langList.value.map((item): RadioListItem => {
return {
label: item.name,
value: normalizeLang(item.lang || ""),
disabled: item.status === 0,
} as RadioListItem;
});
});
const currentLanguageName = computed((): string => {
// 优先寻找接口返回的默认项 (isDefault === 1)
const defaultItem = langList.value.find((item) => item.isDefault === 1);
if (defaultItem != null) {
return defaultItem.name;
}
// 兜底逻辑:从当前语言中匹配
const normCurrent = normalizeLang(currentLang.value);
const currentItem = langList.value.find(
(item) => normalizeLang(item.lang || "") === normCurrent,
);
return currentItem?.name || currentItem?.lang || "Language";
});
const handleOpen = () => {
// 优先同步接口返回的默认语言到弹窗选中态
const defaultItem = langList.value.find((item) => item.isDefault === 1);
if (defaultItem != null) {
localCurrentLang.value = normalizeLang(defaultItem.lang || "");
} else {
localCurrentLang.value = normalizeLang(currentLang.value);
}
visible.value = true;
};
const handleClose = () => {
visible.value = false;
};
const handleLanguageChange = async (targetLang: string) => {
if (!targetLang || targetLang === currentLang.value) return;
uni.showLoading({
title: t("Mobile.Common.switching"),
});
try {
const ok = await setLanguage(targetLang);
if (!ok) {
uni.showToast({
title: t("Mobile.Common.switchFailed"),
icon: "none",
});
return;
}
uni.showToast({
title: t("Mobile.Common.switchSuccess"),
icon: "success",
});
// 切换语言后刷新页面,确保全局语言包生效
setTimeout(() => {
// #ifdef H5
window.location.reload();
// #endif
// #ifdef MP-WEIXIN
uni.relaunch({
url: "/subpackages/pages/login/login",
});
// #endif
}, 500);
} finally {
uni.hideLoading();
visible.value = false;
}
};
</script>
<style lang="scss" scoped>
.login-lang-wrapper {
position: absolute;
top: 40rpx;
right: 32rpx;
z-index: 999;
.login-lang-switcher {
width: fit-content;
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
padding: 12rpx 24rpx;
background-color: rgba(255, 255, 255, 0.2);
border-radius: 100rpx;
border: 1rpx solid rgba(255, 255, 255, 0.3);
cursor: pointer;
/* 确保点击态响应 */
&:active {
opacity: 0.8;
background-color: rgba(255, 255, 255, 0.3);
}
.iconfont {
font-size: 32rpx;
color: #fff;
}
.lang-text {
font-size: 26rpx;
color: #fff;
font-weight: 500;
}
.icon-a-Chevrondown {
font-size: 24rpx;
opacity: 0.8;
}
}
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<view class="container page-container">
<!-- #ifdef H5 -->
<login-lang-switcher />
<!-- #endif -->
<safety-zone />
<!-- #ifdef MP-WEIXIN -->
<custom-nav-bar
v-if="showNavBar"
:show-back="showBack"
:has-safety-zone="false"
:transparent-background="true"
/>
<!-- #endif -->
<view class="content-weixin">
<view class="header-content">
<image
v-if="!!logo"
class="icon"
:src="logo"
mode="aspectFill"
:alt="t('Mobile.Common.appLogoAlt')"
/>
<view class="title"> {{ title }} </view>
</view>
<view class="form-content">
<slot name="default"></slot>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { useI18n } from "@/utils/i18n";
import LoginLangSwitcher from "../login-lang-switcher/login-lang-switcher.uvue";
const { t } = useI18n();
// 定义组件props
interface Props {
title?: string;
logo?: string;
showNavBar?: boolean;
showBack?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
title: "",
logo: "",
showNavBar: true,
showBack: true,
});
</script>
<style lang="scss" scoped>
.container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgb(150, 136, 230) 0%, #f8b1c5 100%);
.content-weixin {
flex: 1;
min-height: 0; /* 允许收缩 */
overflow: auto;
padding: 16rpx;
/* 隐藏滚动条 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
&::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.header-content {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 256rpx;
.icon {
width: 96rpx;
height: 96rpx;
border-radius: 32rpx;
}
.title {
margin-left: 20rpx;
font-weight: 600;
font-style: Semibold;
font-size: 32rpx;
leading-trim: NONE;
line-height: 48rpx;
letter-spacing: 0rpx;
vertical-align: middle;
color: rgba(255, 255, 255, 1);
}
}
.form-content {
flex-shrink: 0; /* 防止内容被压缩 */
padding: 48rpx;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 32rpx;
}
}
}
</style>

View File

@@ -0,0 +1,282 @@
<template>
<view class="captcha-verify">
<view class="captcha-verify-header">
<!-- 返回按钮 -->
<view class="back-box" @tap="handleBack">
<text class="iconfont icon-a-Chevronleft"></text>
</view>
</view>
<view class="captcha-verify-content">
<view class="title">{{ t("Mobile.Auth.setPasswordTitle") }}</view>
<view class="phone-info">{{ t("Mobile.Auth.setPasswordDesc") }}</view>
</view>
<view class="password-input-wrapper">
<input class="password-input" password :placeholder="t('Mobile.Auth.inputPassword')" v-model="password" cursor-color="#5147FF" />
<view class="clear-btn" v-if="password" @click="clearPassword">
<uni-icon class="iconfont icon-a-Xcircle-fill clear-icon" />
</view>
</view>
<view class="password-input-wrapper">
<input class="password-input" password :placeholder="t('Mobile.Auth.inputPasswordAgain')" v-model="confirmPassword" cursor-color="#5147FF" />
<view class="clear-btn" v-if="confirmPassword" @click="clearConfirmPassword">
<uni-icon class="iconfont icon-a-Xcircle-fill clear-icon" />
</view>
</view>
<button class="reset-btn" :class="{ loading: loadingReset }" @click="handleReset" :disabled="loadingReset" :loading="loadingReset">
<text>{{ loadingReset ? t("Mobile.Auth.setting") : t("Mobile.Common.ok") }}</text>
</button>
</view>
</template>
<script lang="uts" setup>
import { apiSetPassword } from '@/servers/account'
import { SUCCESS_CODE } from '@/constants/codes.constants'
import type { ResetPasswordParams } from '@/types/interfaces/login'
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
const props = defineProps({
phoneNumber: {
type: String,
default: ''
},
verificationCode: {
type: String,
default: ''
}
})
const emit = defineEmits(['reset-success', 'back'])
// 密码
const password = ref('')
// 确认密码
const confirmPassword = ref('')
// 登录loading
const loadingReset = ref(false)
// 清除密码
const clearPassword = () => {
password.value = ''
}
// 清除确认密码
const clearConfirmPassword = () => {
confirmPassword.value = ''
}
// 返回登录
const handleBack = () => {
emit('back')
}
// 密码验证
const validatePassword = () => {
if (!password.value) {
uni.showToast({
title: t("Mobile.Auth.inputPassword"),
icon: 'none'
})
return false
}
if (password.value.length < 6) {
uni.showToast({
title: t("Mobile.Auth.passwordMinLength"),
icon: 'none'
})
return false
}
if (!confirmPassword.value) {
uni.showToast({
title: t("Mobile.Auth.confirmPassword"),
icon: 'none'
})
return false
}
if (password.value !== confirmPassword.value) {
uni.showToast({
title: t("Mobile.Auth.passwordMismatch"),
icon: 'none'
})
return false
}
return true
}
// 重置密码
const handleReset = async () => {
if (!validatePassword()) {
return
}
try {
loadingReset.value = true
const { code, message } = await apiSetPassword({password: password.value})
if (code === SUCCESS_CODE) {
uni.showToast({
title: t("Mobile.Auth.passwordSetSuccess"),
icon: 'success'
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/index/index'
})
}, 1000)
} else {
uni.showToast({
title: message || t("Mobile.Auth.passwordSetFailed"),
icon: 'none'
})
}
} catch (error) {
uni.showToast({
title: t("Mobile.Common.networkRetry"),
icon: 'none'
})
} finally {
loadingReset.value = false
}
}
</script>
<style lang="scss" scoped>
.captcha-verify {
overflow: auto;
display: flex;
flex-direction: column;
.captcha-verify-header {
.back-box{
display: inline-block;
width: 58rpx;
height: 58rpx;
line-height: 58rpx;
text-align: center;
background-color: rgba(12, 20, 102, 0.04);
border-radius: 50%;
.iconfont {
font-size: 48rpx;
color: #333;
}
}
}
.captcha-verify-content {
flex: 1;
overflow: auto;
.title {
font-size: 40rpx;
line-height: 56rpx;
font-weight: 600;
color: rgba(21, 23, 31, 1);
margin-bottom: 24rpx;
line-height: 64rpx;
text-align: center;
}
.phone-info {
font-size: 28rpx;
font-weight: 400;
color: rgba(21, 23, 31, 0.7);
margin-bottom: 80rpx;
line-height: 44rpx;
text-align: center;
}
}
.password-input-wrapper {
background: rgba(12, 20, 102, 0.04);
border-radius: 16rpx;
padding: 24rpx 32rpx;
margin-bottom: 36rpx;
position: relative;
border: 2rpx solid transparent;
transition: border-color 0.3s ease;
display: flex;
flex-direction: row;
align-items: center;
// justify-content: center;
&:focus-within {
border-color: #5147FF;
}
.password-input {
width: 100%;
background: transparent;
border: none;
outline: none;
font-size: 32rpx;
color: #15171f;
font-weight: 400;
line-height: 48rpx;
padding-right: 60rpx;
caret-color: #5147FF;
cursor-color: #5147FF;
}
.clear-btn {
position: absolute;
right: 32rpx;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
.clear-icon {
color: #b2b4b8;
font-size: 32rpx;
}
}
}
.reset-btn {
width: 100%;
border-radius: 16rpx;
margin-top: 20rpx;
padding: 32rpx 23rpx;
font-size: 32rpx;
font-weight: 400;
margin-bottom: 32rpx;
line-height: 48rpx;
text-align: center;
background: #5147ff;
color: #ffffff;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
border: none;
}
.reset-btn:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<view>
<login-layout
:title="tenantConfigInfo?.siteName"
:logo="tenantConfigInfo?.siteLogo"
>
<login-form
v-if="!loading"
v-show="currentFormType === 'login'"
:tenantConfigInfo="tenantConfigInfo"
@show-verify="handleShowVerify"
:redirectUrl="redirectUrl"
/>
<captcha-verify
v-if="currentFormType === 'verify'"
:phoneOrEmail="phoneOrEmail"
:tenantConfigInfo="tenantConfigInfo"
@verify-login="handleLoginByCode"
@back="handleBack"
/>
<reset-password v-show="currentFormType === 'reset'" @back="handleBack" />
</login-layout>
</view>
</template>
<script setup lang="uts">
import LoginLayout from "./components/login-layout/login-layout.uvue";
import { ref } from "vue";
import { apiLogin } from "@/servers/account";
import type { ILoginResult, LoginFieldType } from "@/types/interfaces/login";
import { SUCCESS_CODE, AGENT_NOT_EXIST } from "@/constants/codes.constants";
import { ACCESS_TOKEN, EXPIRE_DATE } from "@/constants/home.constants";
import {
apiTenantConfig,
apiSendCode,
apiLoginCode,
} from "@/servers/account";
import type { TenantConfigInfo } from "@/types/interfaces/login";
import CaptchaVerify from "./components/captcha-verify/captcha-verify.uvue";
import SegmentedControl from "@/components/segmented-control/segmented-control.uvue";
import LoginForm from "./components/login-form/login-form.uvue";
import ResetPassword from "./components/reset-password/reset-password.uvue";
import { redirectTo } from "@/utils/common.uts";
import { useI18n } from "@/utils/i18n";
const { t, ensureLangList } = useI18n();
// 当前显示 form 表单类型登录-login/验证码-verify/修改密码-reset
const currentFormType = ref("login");
// 手机号
const phoneOrEmail = ref("");
const captchaVerifyParam = ref(""); // 阿里云验证码参数
const redirectUrl = ref("");
const tenantConfigInfo = ref<TenantConfigInfo>(null); // 配置信息
// 加载中
const loading = ref(false);
// 获取用户配置
const fetchTenantConfig = async () => {
try {
loading.value = true;
const { code, data } = await apiTenantConfig();
const siteName = data?.siteName;
if (code === SUCCESS_CODE && siteName) {
// data.authType = 3; // 临时改变-邮箱登录
tenantConfigInfo.value = data;
uni.setNavigationBarTitle({ title: siteName });
}
} finally {
loading.value = false;
}
};
// 登录
const handleShowVerify = async (value: string, captcha: string) => {
currentFormType.value = "verify";
phoneOrEmail.value = value;
captchaVerifyParam.value = captcha;
};
// 返回登录
const handleBack = () => {
currentFormType.value = "login";
};
// 验证码输入完成登录
const handleLoginByCode = async (code: string) => {
const params = {
phoneOrEmail: phoneOrEmail.value,
code,
captchaVerifyParam: captchaVerifyParam.value,
};
const result = await apiLoginCode(params);
handleLoginSuccess(result);
};
// 登录成功后
const handleLoginSuccess = (result: ILoginResult) => {
const { code, data, message } = result || {};
// 登录失败
if (code === AGENT_NOT_EXIST) {
uni.showToast({
title: message,
icon: "none",
});
return;
}
// 登录成功
if (code === SUCCESS_CODE) {
// #ifdef H5 || WEB
if (process.env.NODE_ENV === "development") {
uni.setStorageSync(ACCESS_TOKEN, data.token);
}
// #endif
// #ifdef MP-WEIXIN
uni.setStorageSync(ACCESS_TOKEN, data.token);
// #endif
// data.resetPass=0
// 判断是否已经修改过密码 0-未修改/1-已修改
if (!data?.resetPass) {
currentFormType.value = "reset";
return;
}
// 验证码登录
uni.showToast({
title: t("Mobile.Auth.loginSuccess"),
icon: "success",
});
setTimeout(() => {
redirectTo(redirectUrl.value);
}, 1000);
}
};
onLoad((query) => {
// 登录成功后跳转的页面
if (query.redirect) {
// 免跳转白名单
const whiteList = ["/subpackages/pages/temporary-session/temporary-session"];
const redirect = decodeURIComponent(query.redirect);
if (!whiteList.includes(redirect)) {
redirectUrl.value = redirect;
} else {
redirectUrl.value = "/pages/index/index";
}
// console.log("登录成功后跳转的页面:", redirectUrl.value)
}
});
onMounted(() => {
fetchTenantConfig();
// #ifdef H5
ensureLangList();
// #endif
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,95 @@
.credits-breakdown {
display: flex;
flex-direction: row;
background: #ffffff;
border-radius: 32rpx;
padding: 48rpx 32rpx;
box-shadow: 0 8rpx 32rpx rgba(30, 109, 235, 0.04);
border: 1rpx solid rgba(30, 109, 235, 0.06);
.credits-column {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 48rpx;
}
.credits-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
width: 100%;
&.clickable:active {
opacity: 0.8;
}
.credits-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10rpx;
}
.credits-label {
font-size: 26rpx;
color: #8c96a0;
font-weight: 500;
}
.credits-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 4rpx 16rpx;
border-radius: 100rpx;
border: 1rpx solid transparent;
&.btn-blue {
background-color: rgba(30, 109, 235, 0.06);
border-color: rgba(30, 109, 235, 0.12);
}
&.btn-green {
background-color: rgba(0, 139, 112, 0.06);
border-color: rgba(0, 139, 112, 0.12);
}
.credits-btn-text {
font-size: 20rpx;
font-weight: 500;
&.text-blue {
color: #1e6deb;
}
&.text-green {
color: #008b70;
}
}
}
.credits-value {
font-size: 48rpx;
font-weight: 700;
color: #1d2129;
&.color-blue {
color: #1e6deb;
}
&.color-green {
color: #008b70;
}
&.color-orange {
color: #e28c00;
}
}
}
}

View File

@@ -0,0 +1,92 @@
<template>
<view class="credits-breakdown">
<!-- 左列 -->
<view class="credits-column">
<view class="credits-item clickable" @tap="handleDetail">
<view class="credits-header">
<text class="credits-label">{{
t("Mobile.MySubscriptions.totalCredits")
}}</text>
<view class="credits-btn btn-blue">
<text class="credits-btn-text text-blue">{{
t("Mobile.MySubscriptions.detail")
}}</text>
</view>
</view>
<text class="credits-value">{{
formatNumber(summary?.totalCredit ?? 0, 0)
}}</text>
</view>
<view class="credits-item clickable" @tap="handleAddPurchase">
<view class="credits-header">
<text class="credits-label">{{
t("Mobile.MySubscriptions.purchaseCredits")
}}</text>
<view class="credits-btn btn-green">
<text class="credits-btn-text text-green">{{
t("Mobile.MySubscriptions.addPurchase")
}}</text>
</view>
</view>
<text class="credits-value color-green">{{
formatNumber(summary?.purchaseCredit ?? 0, 0)
}}</text>
</view>
</view>
<!-- 右列 -->
<view class="credits-column">
<view class="credits-item">
<view class="credits-header">
<text class="credits-label">{{
t("Mobile.MySubscriptions.subscriptionCredits")
}}</text>
</view>
<text class="credits-value color-blue">{{
formatNumber(summary?.subscriptionCredit ?? 0, 0)
}}</text>
</view>
<view class="credits-item">
<view class="credits-header">
<text class="credits-label">{{
t("Mobile.MySubscriptions.activityCredits")
}}</text>
</view>
<text class="credits-value color-orange">{{
formatNumber(summary?.activityCredit ?? 0, 0)
}}</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { CreditSummaryInfo } from "@/subpackages/types/interfaces/subscription";
import { useI18n } from "@/utils/i18n";
import { formatNumber } from "@/utils/system";
const { t } = useI18n();
interface Props {
summary: CreditSummaryInfo | null;
}
defineProps<Props>();
const emit = defineEmits(["add-purchase"]);
const handleAddPurchase = () => {
emit("add-purchase");
};
const handleDetail = () => {
uni.navigateTo({
url: "/subpackages/pages/credit-records/credit-records",
});
};
</script>
<style lang="scss" scoped>
@import "./credits-breakdown.scss";
</style>

View File

@@ -0,0 +1,266 @@
.plan-cards {
margin-top: 24rpx;
.section-title-container {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 36rpx;
margin-bottom: 24rpx;
.section-title-bar {
width: 6rpx;
height: 30rpx;
background: linear-gradient(180deg, #1e6deb 0%, #008b70 100%);
border-radius: 4rpx;
margin-right: 12rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #1d2129;
line-height: 32rpx;
}
}
.cards-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.plan-card {
background: #fff;
border-radius: 16rpx;
padding: 0;
border: 2rpx solid #e5e6eb;
position: relative;
overflow: hidden;
&.plan-current {
border: 2rpx solid #1e6deb;
background: rgba(30, 109, 235, 0.02);
}
.plan-badges {
position: absolute;
top: -2rpx;
right: -2rpx;
z-index: 10;
.badge {
height: 52rpx;
padding: 0 28rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
border-radius: 0 16rpx 0 24rpx;
.badge-text {
font-size: 24rpx;
font-weight: 600;
color: #fff;
line-height: 24rpx;
}
&.badge-active {
background: #1e6deb;
}
&.badge-hot {
background: linear-gradient(135deg, #ff9465 0%, #ff5252 100%);
}
}
}
.plan-card-body {
padding: 36rpx 32rpx 40rpx 32rpx;
display: flex;
flex-direction: column;
.plan-name {
font-size: 32rpx;
font-weight: 600;
color: #1d2129;
margin-bottom: 8rpx;
padding-right: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan-desc {
font-size: 24rpx;
color: #86909c;
margin-bottom: 16rpx;
line-height: 1.4;
padding-right: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.plan-price-row {
display: flex;
flex-direction: row;
align-items: baseline;
margin-bottom: 12rpx;
.plan-price-symbol {
font-size: 32rpx;
font-weight: 700;
color: #1e6deb;
margin-right: 4rpx;
}
.plan-price-value {
font-size: 56rpx;
font-weight: 800;
color: #1e6deb;
line-height: 56rpx;
}
.plan-period {
font-size: 26rpx;
color: #4e5969;
font-weight: 500;
margin-left: 8rpx;
}
}
.plan-credits-tag {
align-self: flex-start;
background: #e8f3ff;
border-radius: 8rpx;
padding: 6rpx 16rpx;
margin-bottom: 24rpx;
display: flex;
flex-direction: row;
align-items: center;
.plan-credits-text {
font-size: 24rpx;
font-weight: 600;
color: #1e6deb;
line-height: 24rpx;
}
}
.benefits {
margin-bottom: 24rpx;
.benefit-group {
display: flex;
flex-direction: column;
gap: 20rpx;
.benefit-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
.benefit-icon-container {
width: 32rpx;
height: 32rpx;
border-radius: 16rpx;
background-color: #f2f3f5;
display: flex;
align-items: center;
justify-content: center;
.benefit-icon-text {
font-size: 18rpx;
font-weight: bold;
line-height: 18rpx;
}
&.icon-check {
.benefit-icon-text {
color: #4e5969;
}
}
&.icon-cross {
.benefit-icon-text {
color: #86909c;
}
}
}
.benefit-text {
font-size: 26rpx;
color: #1d2129;
line-height: 36rpx;
}
}
}
}
.plan-action-area {
margin-bottom: 32rpx;
width: 100%;
.plan-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;
}
&.plan-btn-processing {
opacity: 0.6;
}
&.plan-btn-current {
background: #efebe6;
.plan-btn-text {
color: #5c564f;
}
}
&.plan-btn-disabled {
background: #f5f5f5;
box-shadow: none;
pointer-events: none;
.plan-btn-text {
color: #c0c4cc;
}
&:active {
transform: none;
opacity: 1;
}
}
.plan-btn-text {
font-size: 26rpx;
color: #fff;
font-weight: 600;
line-height: 26rpx;
}
}
}
.plan-divider {
margin: 8rpx 0 24rpx;
border-top: 2rpx dashed #e5e6eb;
}
}
}
}

View File

@@ -0,0 +1,178 @@
<template>
<view class="plan-cards">
<view class="section-title-container">
<view class="section-title-bar"></view>
<text class="section-title">{{
t("Mobile.MySubscriptions.planGrid")
}}</text>
</view>
<view class="cards-list">
<view
v-for="plan in plans"
:key="plan.id"
class="plan-card"
:class="{ 'plan-current': plan.id === currentPlanId }"
>
<!-- 标签 -->
<view class="plan-badges">
<view v-if="plan.id === currentPlanId" class="badge badge-active">
<text class="badge-text">{{
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="plan-card-body">
<!-- 套餐信息 -->
<text class="plan-name">{{ plan.name }}</text>
<text v-if="plan.description" class="plan-desc">{{
plan.description
}}</text>
<view class="plan-price-row">
<text class="plan-price-symbol">¥</text>
<text class="plan-price-value">{{
formatNumber(plan.price, 2)
}}</text>
<text class="plan-period">{{ getPeriodUnit(plan.period) }}</text>
</view>
<view class="plan-credits-tag">
<text class="plan-credits-text">
{{
`${formatNumber(plan.creditAmount, 0)} ${t("Mobile.MySubscriptions.creditUnit")}${t("Mobile.MySubscriptions.perMonth")}`
}}
</text>
</view>
<!-- 按钮(移入副标题及价格信息下方,与 PC 端保持一致) -->
<view class="plan-action-area">
<view
class="plan-btn"
:class="{
'plan-btn-processing': processingId === plan.id,
'plan-btn-current': plan.id === currentPlanId,
'plan-btn-disabled':
plan.price <= 0 && plan.id !== currentPlanId,
}"
@tap="handleAction(plan)"
>
<text class="plan-btn-text">{{ getButtonText(plan) }}</text>
</view>
</view>
<!-- 分割线(与 PC 端保持一致) -->
<view class="plan-divider"></view>
<!-- 权益列表 -->
<view class="benefits" v-if="getBaseBenefits(plan).length > 0">
<view class="benefit-group">
<view
v-for="(item, index) in getBaseBenefits(plan)"
:key="index"
class="benefit-item"
>
<view
class="benefit-icon-container"
:class="item.selected ? 'icon-check' : 'icon-cross'"
>
<text class="benefit-icon-text">{{
item.selected ? "✓" : "✗"
}}</text>
</view>
<text class="benefit-text">{{ item.description }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type {
SystemSubscriptionPlan,
SystemSubscriptionPlanGroup,
SystemSubscriptionPlanItem,
} from "@/subpackages/types/interfaces/subscription";
import { useI18n } from "@/utils/i18n";
import { getPeriodUnit } from "../../utils.uts";
import { formatDate, formatNumber } from "@/utils/system";
import { useSubscriptionPurchase } from "../../hooks/useSubscriptionPurchase";
const { t } = useI18n();
const { processingId, handlePaySubscription } = useSubscriptionPurchase();
interface Props {
plans: SystemSubscriptionPlan[];
currentPlanId: number | null;
currentEndTime: string | null;
currentPlanPrice?: number | null;
}
const props = withDefaults(defineProps<Props>(), {
currentPlanPrice: null,
});
const getActionVerb = (planPrice: number): string => {
if (props.currentPlanId == null) {
return t("Mobile.MySubscriptions.subscribeNow");
}
const currentPrice = props.currentPlanPrice ?? 0;
if (planPrice <= currentPrice) {
return t("Mobile.MySubscriptions.subscribeNow");
}
return t("Mobile.MySubscriptions.upgradeNow");
};
const getButtonText = (plan: SystemSubscriptionPlan): string => {
if (plan.id === props.currentPlanId) {
const currentPlanBtnText = t("Mobile.MySubscriptions.currentPlanButton");
const renewNowText = t("Mobile.MySubscriptions.renewNow");
return `${currentPlanBtnText}(${renewNowText})`;
}
const verbText = getActionVerb(plan.price);
let periodText = t("Mobile.MySubscriptions.continuousMonthly");
if (plan.period === 3) {
periodText = t("Mobile.MySubscriptions.continuousQuarterly");
} else if (plan.period === 12) {
periodText = t("Mobile.MySubscriptions.continuousYearly");
}
return `${verbText}${plan.name}${periodText}`;
};
const handleAction = async (plan: SystemSubscriptionPlan) => {
if (plan.price <= 0 && plan.id !== props.currentPlanId) return;
await handlePaySubscription(plan.id);
};
const getBaseBenefits = (
plan: SystemSubscriptionPlan,
): SystemSubscriptionPlanItem[] => {
const list = [] as SystemSubscriptionPlanItem[];
const groups = plan.itemGroups;
if (groups != null) {
for (let i = 0; i < groups.length; i++) {
const g = groups[i];
if (g.groupType === "BASE" && g.items != null) {
for (let j = 0; j < g.items.length; j++) {
list.push(g.items[j]);
}
}
}
}
return list;
};
</script>
<style lang="scss" scoped>
@import "./plan-cards.scss";
</style>

View File

@@ -0,0 +1,85 @@
.purchase-modal {
padding: 16rpx 0;
.purchase-subtitle {
font-size: 24rpx;
color: #999;
margin-bottom: 24rpx;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 60rpx 0;
.loading-text {
font-size: 28rpx;
color: #999;
}
}
.package-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.package-card {
background: #f9f9f9;
border-radius: 12rpx;
padding: 24rpx;
border: 2rpx solid transparent;
&:active {
background: #f0f0f0;
}
&.package-processing {
opacity: 0.6;
}
.package-header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8rpx;
margin-bottom: 12rpx;
.package-name {
font-size: 28rpx;
font-weight: 600;
color: #1d2129;
}
.package-remark {
margin-top: 4rpx;
.remark-text {
font-size: 24rpx;
color: #86909c;
line-height: 34rpx;
}
}
}
.package-footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
.package-credit {
font-size: 26rpx;
color: #1a6bff;
font-weight: 500;
}
.package-price {
font-size: 32rpx;
font-weight: 700;
color: #f59e0b;
}
}
}
}

View File

@@ -0,0 +1,92 @@
<template>
<modal-popup
ref="modalRef"
:title="t('Mobile.MySubscriptions.purchaseTitle')"
>
<view class="purchase-modal">
<text class="purchase-subtitle">{{
t("Mobile.MySubscriptions.purchaseSubtitle")
}}</text>
<view v-if="loading" class="loading-state">
<text class="loading-text">...</text>
</view>
<view v-else class="package-list">
<view
v-for="pkg in packages"
:key="pkg.id"
class="package-card"
:class="{ 'package-processing': processingId === pkg.id }"
@tap="handlePurchase(pkg)"
>
<view class="package-header">
<text class="package-name">{{ pkg.packageName }}</text>
<view v-if="pkg.remark" class="package-remark">
<text class="remark-text">{{ pkg.remark }}</text>
</view>
</view>
<view class="package-footer">
<text class="package-credit">{{
`${formatNumber(pkg.creditAmount, 0)} ${t("Mobile.MySubscriptions.creditPackageCredit")}`
}}</text>
<text class="package-price">{{
`¥ ${formatNumber(pkg.price, 2)}`
}}</text>
</view>
</view>
</view>
</view>
</modal-popup>
</template>
<script setup lang="uts">
import type { CreditPackageInfo } from "@/subpackages/types/interfaces/subscription";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { apiListCreditPackages } from "@/subpackages/servers/subscription";
import { useI18n } from "@/utils/i18n";
import { formatNumber } from "@/utils/system";
import ModalPopup from "@/components/modal-popup/modal-popup.uvue";
import { useSubscriptionPurchase } from "../../hooks/useSubscriptionPurchase";
const { t } = useI18n();
const { processingId, handlePayCredits } = useSubscriptionPurchase();
const modalRef = ref<any>(null);
const packages = ref<CreditPackageInfo[]>([]);
const loading = ref(false);
const open = async () => {
modalRef.value?.open();
await fetchPackages();
};
const close = () => {
modalRef.value?.close();
};
const fetchPackages = async () => {
loading.value = true;
try {
const res = await apiListCreditPackages();
if (res.code === SUCCESS_CODE && res.data) {
const data = res.data as any;
packages.value = Array.isArray(data) ? data : data.data || [];
}
} catch (e) {
// ignore
} finally {
loading.value = false;
}
};
const handlePurchase = async (pkg: CreditPackageInfo) => {
await handlePayCredits(pkg.id);
};
defineExpose({ open, close });
</script>
<style lang="scss" scoped>
@import "./purchase-modal.scss";
</style>

View File

@@ -0,0 +1,314 @@
<template>
<view class="subscribed-agents">
<view v-if="loading" class="loading-state">
<text class="loading-text">...</text>
</view>
<view v-else-if="list.length === 0" class="empty-wrapper">
<empty-state text="Mobile.MySubscriptions.empty" />
</view>
<view v-else class="agent-list">
<view v-for="item in list" :key="item.id" class="agent-card">
<!-- 头部 -->
<view class="agent-header">
<view
class="agent-icon"
:style="{ backgroundColor: item.icon ? 'transparent' : '#1e6deb' }"
>
<image
v-if="item.icon"
:src="item.icon"
class="agent-icon-img"
mode="aspectFill"
/>
<text v-else class="iconfont icon-Grid"></text>
</view>
<view class="agent-title-box">
<text class="agent-name">{{ item.bizName }}</text>
<text class="agent-desc" v-if="item.planName">{{
item.planName
}}</text>
</view>
</view>
<!-- 信息网格 -->
<view class="agent-grid">
<view class="grid-item">
<text class="grid-label">{{
t("Mobile.MySubscriptions.subAmount")
}}</text>
<view class="grid-value-row">
<text class="amount-val"
>¥{{ formatNumber(item.plan?.price ?? 0, 2) }}</text
>
<text class="amount-unit">{{ getPeriodUnit(item.period) }}</text>
</view>
</view>
<view class="grid-item" v-if="item.endTime">
<text class="grid-label">{{
t("Mobile.MySubscriptions.expireTime")
}}</text>
<text class="grid-value">{{
formatDate(item.endTime || "", "YYYY-MM-DD")
}}</text>
</view>
</view>
<!-- 底部 -->
<view class="agent-footer">
<view
class="status-text"
:class="isActive(item.status) ? 'status-active' : 'status-expired'"
>
<text class="icon-char">{{
isActive(item.status) ? "✓" : "✗"
}}</text>
<text class="text">{{ getStatusText(item.status) }}</text>
</view>
<view class="renew-btn" @tap="handleRenew(item)">
<text class="btn-text">{{
t("Mobile.MySubscriptions.renewNow")
}}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { MySubscriptionItem } from "@/subpackages/types/interfaces/subscription";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { apiGetMySubscription } from "@/subpackages/servers/subscription";
import { useI18n } from "@/utils/i18n";
import { useSubscriptionPurchase } from "../../../hooks/useSubscriptionPurchase.uts";
import { getPeriodUnit, getStatusText, isActive } from "../../../utils.uts";
import EmptyState from "@/components/empty-state/empty-state.uvue";
import { formatDate, formatNumber } from "@/utils/system";
const { t } = useI18n();
const list = ref<MySubscriptionItem[]>([]);
const loading = ref(true);
const fetchData = async () => {
loading.value = true;
try {
const res = await apiGetMySubscription({ bizType: "AGENT" });
if (res.code === SUCCESS_CODE && res.data) {
list.value = res.data.subscriptions || [];
}
} catch (e) {
// ignore
} finally {
loading.value = false;
}
};
const { handlePaySubscription, processingId } = useSubscriptionPurchase();
const handleRenew = async (item: MySubscriptionItem) => {
await handlePaySubscription(item.planId);
};
onMounted(() => {
fetchData();
});
</script>
<style lang="scss" scoped>
.subscribed-agents {
min-height: 200rpx;
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
.loading-text {
font-size: 28rpx;
color: #999;
}
}
.empty-wrapper {
padding: 40rpx 0;
}
.agent-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.agent-card {
display: flex;
flex-direction: column;
background: #fff;
border-radius: 16rpx;
padding: 28rpx 28rpx 0 28rpx;
border: 2rpx solid #e5e6eb;
overflow: hidden;
.agent-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 24rpx;
margin-bottom: 40rpx;
.agent-icon {
width: 96rpx;
height: 96rpx;
border-radius: 20rpx;
background: #1e6deb;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 16rpx rgba(30, 109, 235, 0.15);
overflow: hidden;
.agent-icon-img {
width: 100%;
height: 100%;
border-radius: 20rpx;
}
.iconfont {
font-size: 48rpx;
color: #fff;
}
}
.agent-title-box {
display: flex;
flex-direction: column;
gap: 12rpx;
flex: 1;
overflow: hidden;
.agent-name {
font-size: 32rpx;
font-weight: 600;
color: #1d2129;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-desc {
font-size: 24rpx;
color: #8c96a0;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
}
.agent-grid {
display: flex;
flex-direction: row;
margin-bottom: 28rpx;
.grid-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
.grid-label {
font-size: 24rpx;
color: #8c96a0;
}
.grid-value {
font-size: 28rpx;
font-weight: 600;
color: #1d2129;
}
.grid-value-row {
display: flex;
flex-direction: row;
align-items: baseline;
.amount-val {
font-size: 28rpx;
font-weight: 600;
color: #5147ff;
}
.amount-unit {
font-size: 24rpx;
color: #8c96a0;
margin-left: 4rpx;
}
}
}
}
.agent-footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin: 0 -28rpx 0 -28rpx;
padding: 24rpx 28rpx;
background: #f8fafc;
border-top: 2rpx solid #e5e6eb;
.status-text {
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
.icon-char {
font-size: 26rpx;
}
.text {
font-size: 26rpx;
font-weight: 500;
}
&.status-active {
color: #00b42a;
}
&.status-expired {
color: #f53f3f;
}
}
.renew-btn {
display: flex;
align-items: center;
justify-content: center;
height: 64rpx;
background: #1e6deb;
padding: 0 36rpx;
border-radius: 12rpx;
.btn-text {
font-size: 26rpx;
color: #fff;
font-weight: 600;
}
&:active {
opacity: 0.8;
transform: scale(0.98);
}
}
}
}
}
</style>

View File

@@ -0,0 +1,31 @@
.subscribed-content {
margin-top: 24rpx;
.section-title-container {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 36rpx;
margin-bottom: 24rpx;
.section-title-bar {
width: 6rpx;
height: 30rpx;
background: linear-gradient(180deg, #1e6deb 0%, #008b70 100%);
border-radius: 4rpx;
margin-right: 12rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #1d2129;
line-height: 32rpx;
}
}
.tab-panel {
margin-top: 20rpx;
min-height: 400rpx;
}
}

View File

@@ -0,0 +1,46 @@
<template>
<view class="subscribed-content">
<view class="section-title-container">
<view class="section-title-bar"></view>
<text class="section-title">{{ t("Mobile.MySubscriptions.subscribedContent") }}</text>
</view>
<segmented-control
:options="tabOptions"
:default-index="0"
@change="handleTabChange"
/>
<view class="tab-panel">
<SubscribedAgents v-show="activeTab === 'agents'" />
<SubscribedSkills v-show="activeTab === 'skills'" />
<SubscribedCredits v-show="activeTab === 'credits'" />
</view>
</view>
</template>
<script setup lang="uts">
import { useI18n } from '@/utils/i18n';
import SegmentedControl from '@/components/segmented-control/segmented-control.uvue';
import SubscribedAgents from './subscribed-agents/subscribed-agents.uvue';
import SubscribedSkills from './subscribed-skills/subscribed-skills.uvue';
import SubscribedCredits from './subscribed-credits/subscribed-credits.uvue';
const { t } = useI18n();
const activeTab = ref<string>('agents');
const tabOptions = [
{ label: 'Mobile.MySubscriptions.tabAgents', value: 'agents' },
{ label: 'Mobile.MySubscriptions.tabSkills', value: 'skills' },
{ label: 'Mobile.MySubscriptions.tabCredits', value: 'credits' },
];
const handleTabChange = (value: string | number) => {
activeTab.value = value as string;
};
</script>
<style lang="scss" scoped>
@import './subscribed-content.scss';
</style>

View File

@@ -0,0 +1,283 @@
<template>
<view class="subscribed-credits">
<view v-if="loading" class="loading-state">
<text class="loading-text">...</text>
</view>
<view v-else-if="list.length === 0" class="empty-wrapper">
<empty-state text="Mobile.MySubscriptions.empty" />
</view>
<view v-else class="credit-list">
<view v-for="item in list" :key="item.id" class="credit-card">
<!-- 头部 -->
<view class="credit-header">
<text class="credit-name">{{ item.remark || item.batchNo }}</text>
<text class="credit-date">{{
formatDate(item.created || "", "YYYY-MM-DD")
}}</text>
</view>
<!-- 数据四宫格 -->
<view class="credit-grid">
<view class="grid-item">
<text class="grid-label">{{
t("Mobile.MySubscriptions.totalCredits")
}}</text>
<text class="grid-value"
>+{{ formatNumber(item.totalAmount, 0) }}</text
>
</view>
<view class="grid-item">
<text class="grid-label">{{
t("Mobile.MySubscriptions.usedAmount")
}}</text>
<text class="grid-value">{{
formatNumber(item.usedAmount, 0)
}}</text>
</view>
<view class="grid-item">
<text class="grid-label">{{
t("Mobile.MySubscriptions.expireTime")
}}</text>
<text class="grid-value">{{
item.expireTime
? formatDate(item.expireTime || "", "YYYY-MM-DD")
: "-"
}}</text>
</view>
<view class="grid-item">
<text class="grid-label">{{
t("Mobile.MySubscriptions.purchaseAmount")
}}</text>
<text
class="grid-value highlight-price"
>{{ item.extra?.price != null ? `¥ ${formatNumber(item.extra!.price, 2)}` : '-' }}</text
>
</view>
</view>
<!-- 底部 -->
<view class="credit-footer">
<text class="footer-remain">
{{
`${t("Mobile.MySubscriptions.remaining")}${formatNumber(item.remainAmount, 0)} ${t("Mobile.MySubscriptions.creditUnit")}`
}}
</text>
<view
v-if="getCreditStatus(item)"
class="status-tag"
:class="getCreditStatus(item)?.cls"
>
<text class="status-text">{{ getCreditStatus(item)?.text }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { CreditBatchItem } from "@/subpackages/types/interfaces/subscription";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { apiGetCreditBatches } from "@/subpackages/servers/subscription";
import { useI18n } from "@/utils/i18n";
import EmptyState from "@/components/empty-state/empty-state.uvue";
import { formatDate, formatNumber } from "@/utils/system";
const { t } = useI18n();
const list = ref<CreditBatchItem[]>([]);
const loading = ref(true);
interface CreditStatus {
text: string;
cls: string;
}
const getCreditStatus = (item: CreditBatchItem): CreditStatus | null => {
if (item.expired) {
return {
text: t("Mobile.MySubscriptions.expired"),
cls: "status-expired",
};
}
if (item.remainAmount <= 0) {
return {
text: t("Mobile.MySubscriptions.fullyUsed"),
cls: "status-expired",
};
}
const ratio = item.totalAmount > 0 ? item.usedAmount / item.totalAmount : 0;
if (ratio >= 0.8) {
return {
text: t("Mobile.MySubscriptions.aboutToUseUp"),
cls: "status-warning",
};
}
return {
text: t("Mobile.MySubscriptions.normalUsage"),
cls: "status-active",
};
};
const fetchData = async () => {
loading.value = true;
try {
const res = await apiGetCreditBatches({ creditType: "PURCHASE" });
if (res.code === SUCCESS_CODE && res.data) {
list.value = Array.isArray(res.data) ? res.data : [];
}
} catch (e) {
// ignore
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchData();
});
</script>
<style lang="scss" scoped>
.subscribed-credits {
min-height: 200rpx;
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
.loading-text {
font-size: 28rpx;
color: #999;
}
}
.empty-wrapper {
padding: 40rpx 0;
}
.credit-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.credit-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
overflow: hidden;
border: 2rpx solid #e5e6eb;
.credit-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
.credit-name {
font-size: 28rpx;
font-weight: 600;
color: #333;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16rpx;
}
.credit-date {
font-size: 22rpx;
color: #999;
flex-shrink: 0;
}
}
.credit-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx;
padding: 20rpx 0;
border-top: 2rpx solid #e5e6eb;
.grid-item {
width: calc(50% - 8rpx);
display: flex;
flex-direction: column;
gap: 8rpx;
.grid-label {
font-size: 22rpx;
color: #999;
}
.grid-value {
font-size: 28rpx;
font-weight: 600;
color: #333;
&.highlight-price {
color: #f59e0b;
}
}
}
}
.credit-footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin: 20rpx -24rpx -24rpx -24rpx;
padding: 24rpx 24rpx;
background: #f8fafc;
border-top: 2rpx solid #e5e6eb;
.footer-remain {
font-size: 24rpx;
color: #666;
}
.status-tag {
padding: 4rpx 16rpx;
border-radius: 6rpx;
.status-text {
font-size: 22rpx;
font-weight: 500;
}
&.status-active {
background: rgba(82, 196, 26, 0.1);
.status-text {
color: #52c41a;
}
}
&.status-warning {
background: rgba(245, 158, 11, 0.1);
.status-text {
color: #f59e0b;
}
}
&.status-expired {
background: rgba(255, 77, 79, 0.1);
.status-text {
color: #ff4d4f;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,296 @@
<template>
<view class="subscribed-skills">
<view v-if="loading" class="loading-state">
<text class="loading-text">...</text>
</view>
<view v-else-if="list.length === 0" class="empty-wrapper">
<empty-state text="Mobile.MySubscriptions.empty" />
</view>
<view v-else class="skill-list">
<view v-for="item in list" :key="item.id" class="skill-card">
<view class="skill-header">
<view class="skill-header-left">
<view class="skill-icon" :style="{ backgroundColor: item.icon ? 'transparent' : '#5147ff' }">
<image v-if="item.icon" :src="item.icon" class="skill-icon-img" mode="aspectFill" />
<text v-else class="iconfont icon-Grid"></text>
</view>
<view class="skill-title-box">
<text class="skill-name">{{ item.bizName }}</text>
<text class="skill-desc" v-if="item.planName">{{ item.planName }}</text>
</view>
</view>
<view class="skill-header-right">
<!-- 买断 -->
<view v-if="isForever(item.period)" class="tag tag-bought">
<text class="tag-text">{{
t("Mobile.MySubscriptions.boughtOut")
}}</text>
</view>
<!-- 订阅状态 -->
<view
v-else
class="status-tag"
:class="isActive(item.status) ? 'status-active' : 'status-expired'"
>
<text class="status-text">{{ getStatusText(item.status) }}</text>
</view>
</view>
</view>
<view class="skill-body">
<!-- 买断模式 -->
<view v-if="isForever(item.period)" class="skill-row">
<text class="skill-label">{{
t("Mobile.MySubscriptions.buyoutPriceLabel")
}}</text>
<text class="skill-value">{{
`¥ ${formatNumber(item.plan?.price ?? 0, 2)}`
}}</text>
</view>
<view v-if="isForever(item.period)" class="skill-row">
<text class="skill-label">{{
t("Mobile.MySubscriptions.permanentUse")
}}</text>
</view>
<!-- 订阅模式 -->
<view v-if="!isForever(item.period)" class="skill-row">
<text class="skill-label">{{
t("Mobile.MySubscriptions.subAmount")
}}</text>
<text class="skill-value">
{{
`¥ ${formatNumber(item.plan?.price ?? 0, 2)} ${getPeriodUnit(item.period)}`
}}
</text>
</view>
<view v-if="!isForever(item.period)" class="skill-row">
<text class="skill-label">{{
t("Mobile.MySubscriptions.expireTime")
}}</text>
<text class="skill-value">{{
item.endTime ? formatDate(item.endTime || "", "YYYY-MM-DD") : "-"
}}</text>
</view>
<view v-if="!isForever(item.period)" class="skill-row">
<text class="skill-label">{{ getPayTypeText(item.period) }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import type { MySubscriptionItem } from "@/subpackages/types/interfaces/subscription";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { apiGetMySubscription } from "@/subpackages/servers/subscription";
import { useI18n } from "@/utils/i18n";
import {
getPeriodUnit,
getStatusText,
isActive,
isForever,
getPayTypeText,
} from "../../../utils.uts";
import EmptyState from "@/components/empty-state/empty-state.uvue";
import { formatDate, formatNumber } from "@/utils/system";
const { t } = useI18n();
const list = ref<MySubscriptionItem[]>([]);
const loading = ref(true);
const fetchData = async () => {
loading.value = true;
try {
const res = await apiGetMySubscription({ bizType: "SKILL" });
if (res.code === SUCCESS_CODE && res.data) {
list.value = res.data.subscriptions || [];
}
} catch (e) {
// ignore
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchData();
});
</script>
<style lang="scss" scoped>
.subscribed-skills {
min-height: 200rpx;
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
.loading-text {
font-size: 28rpx;
color: #999;
}
}
.empty-wrapper {
padding: 40rpx 0;
}
.skill-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skill-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
.skill-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
.skill-header-left {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
overflow: hidden;
margin-right: 12rpx;
.skill-icon {
width: 72rpx;
height: 72rpx;
border-radius: 12rpx;
background: #5147ff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.skill-icon-img {
width: 100%;
height: 100%;
border-radius: 12rpx;
}
.iconfont {
font-size: 36rpx;
color: #fff;
}
}
.skill-title-box {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
overflow: hidden;
.skill-name {
font-size: 28rpx;
font-weight: 600;
color: #333;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-desc {
font-size: 22rpx;
color: #999;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
}
.skill-header-right {
flex-shrink: 0;
.tag {
padding: 4rpx 16rpx;
border-radius: 6rpx;
.tag-text {
font-size: 22rpx;
font-weight: 500;
color: #5147ff;
}
&.tag-bought {
background: rgba(81, 71, 255, 0.1);
}
}
.status-tag {
padding: 4rpx 16rpx;
border-radius: 6rpx;
.status-text {
font-size: 22rpx;
font-weight: 500;
}
&.status-active {
background: rgba(82, 196, 26, 0.1);
.status-text {
color: #52c41a;
}
}
&.status-expired {
background: rgba(255, 77, 79, 0.1);
.status-text {
color: #ff4d4f;
}
}
}
}
}
.skill-body {
display: flex;
flex-direction: column;
gap: 12rpx;
.skill-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
.skill-label {
font-size: 24rpx;
color: #999;
}
.skill-value {
font-size: 24rpx;
color: #333;
font-weight: 500;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,241 @@
import { ref, onMounted } from "vue";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import { ACCESS_TOKEN } from "@/constants/home.constants";
import {
apiCreateSubscriptionOrder,
apiCreateCreditOrder,
apiGetOrderCashier,
} from "@/subpackages/servers/subscription";
import { useI18n } from "@/utils/i18n";
import { API_BASE_URL } from "@/constants/config";
import { objectToQueryString } from "@/utils/common";
// 全局单例锁:记录已展示过 Toast 的 订单ID + 支付结果 映射,防止多组件重复提示
const alertedKeys = new Set<string>();
export function useSubscriptionPurchase() {
const { t } = useI18n();
const processingId = ref<number | null>(null);
const loading = ref<boolean>(false);
// #ifdef H5 || WEB
const cleanUrlParams = () => {
// 清空 window.location.search 中的参数
const search = window.location.search;
if (search !== "") {
const params = new URLSearchParams(search);
if (params.has("payResult") || params.has("orderId")) {
params.delete("payResult");
params.delete("orderId");
const searchStr = params.toString();
const newSearch = searchStr ? `?${searchStr}` : "";
const newUrl =
window.location.pathname + newSearch + window.location.hash;
window.history.replaceState(null, "", newUrl);
}
}
// 清空 window.location.hash 中的参数
const hash = window.location.hash;
const qIndex = hash.indexOf("?");
if (qIndex !== -1) {
const pathPart = hash.substring(0, qIndex);
const hashSearch = hash.substring(qIndex);
const params = new URLSearchParams(hashSearch);
if (params.has("payResult") || params.has("orderId")) {
params.delete("payResult");
params.delete("orderId");
const searchStr = params.toString();
const newHash = pathPart + (searchStr ? `?${searchStr}` : "");
const newUrl =
window.location.pathname + window.location.search + newHash;
window.history.replaceState(null, "", newUrl);
}
}
};
const checkPayResult = () => {
const hash = window.location.hash;
const search = window.location.search;
let payResult = "";
let orderId = "";
// 优先从 main search 中解析
if (search !== "") {
const searchParams = new URLSearchParams(search);
payResult = searchParams.get("payResult") || "";
orderId = searchParams.get("orderId") || "";
}
// 若无,从 hash search 中解析
if (payResult === "") {
const qIndex = hash.indexOf("?");
if (qIndex !== -1) {
const hashSearch = hash.substring(qIndex);
const hashParams = new URLSearchParams(hashSearch);
payResult = hashParams.get("payResult") || "";
orderId = hashParams.get("orderId") || "";
}
}
if (payResult !== "") {
const alertKey = `${orderId}_${payResult}`;
if (alertedKeys.has(alertKey)) return;
alertedKeys.add(alertKey);
if (payResult === "success") {
uni.showToast({
title: t("Mobile.MySubscriptions.paySuccess"),
icon: "success",
});
} else if (payResult === "failed") {
uni.showToast({
title: t("Mobile.MySubscriptions.payFailed"),
icon: "none",
});
} else if (payResult === "pending") {
uni.showToast({
title: t("Mobile.MySubscriptions.payPending"),
icon: "none",
});
}
// 清除 URL 参数
cleanUrlParams();
}
};
// #endif
onMounted(() => {
// #ifdef H5 || WEB
checkPayResult();
// #endif
});
const getCashierUrlAndRedirect = async (orderId: number) => {
loading.value = true;
try {
let settlementUrl = "";
// #ifdef H5 || WEB
const token = (uni.getStorageSync(ACCESS_TOKEN) as string) || "";
let settlementOrigin = "";
const hostname = window.location.hostname;
if (hostname === "localhost" || hostname === "127.0.0.1") {
settlementOrigin = "http://localhost:3000";
settlementUrl = `${settlementOrigin}/static/payment-settlement.html?orderId=${orderId}&returnUrl=${encodeURIComponent(window.location.href)}&token=${encodeURIComponent(token)}`;
} else {
settlementOrigin = window.location.origin;
settlementUrl = `${settlementOrigin}/static/payment-settlement.html?orderId=${orderId}&returnUrl=${encodeURIComponent(window.location.href)}`;
}
// #endif
// #ifndef H5 || WEB
const token = (uni.getStorageSync(ACCESS_TOKEN) as string) || "";
const pages = getCurrentPages();
let relativePath = "";
if (pages.length > 0) {
const currentPage = pages[pages.length - 1];
const path = currentPage.route;
const params = objectToQueryString(currentPage.options);
relativePath = params !== "" ? `${path}?${params}` : path;
}
let baseSettlementUrl = API_BASE_URL;
if (process.env.NODE_ENV === "development") {
baseSettlementUrl = "http://localhost:3000";
}
const h5CurrentPageUrl = `${baseSettlementUrl}/m/#/${relativePath}`;
settlementUrl = `${baseSettlementUrl}/static/payment-settlement.html?orderId=${orderId}&returnUrl=${encodeURIComponent(h5CurrentPageUrl)}&token=${encodeURIComponent(token)}&isMp=1`;
// #endif
// 获取收银台地址
const cashierRes = await apiGetOrderCashier(
orderId,
settlementUrl !== "" ? settlementUrl : undefined,
);
if (cashierRes.code === SUCCESS_CODE && cashierRes.data != null) {
const cashierInfo = cashierRes.data!;
// #ifdef H5 || WEB
// H5 环境直接重定向跳转到收银台
window.location.href = cashierInfo.cashierUrl;
// #endif
// #ifndef H5 || WEB
// 非 H5 环境(如 App 或 小程序)则打开内嵌 WebView 进行支付
uni.navigateTo({
url: `/subpackages/pages/webview/webview?url=${encodeURIComponent(cashierInfo.cashierUrl)}`,
});
// #endif
} else {
uni.showToast({
title: t("Mobile.MySubscriptions.orderIdNotFound"),
icon: "none",
});
}
} catch (e) {
uni.showToast({
title: t("Mobile.MySubscriptions.loadFailed"),
icon: "none",
});
} finally {
loading.value = false;
processingId.value = null;
}
};
const handlePaySubscription = async (planId: number) => {
if (processingId.value != null) return;
processingId.value = planId;
try {
const orderRes = await apiCreateSubscriptionOrder(planId);
if (orderRes.code === SUCCESS_CODE && orderRes.data) {
const orderId = orderRes.data.id ?? orderRes.data;
await getCashierUrlAndRedirect(orderId as number);
} else {
uni.showToast({
title: t("Mobile.MySubscriptions.orderIdNotFound"),
icon: "none",
});
processingId.value = null;
}
} catch (e) {
uni.showToast({
title: t("Mobile.MySubscriptions.loadFailed"),
icon: "none",
});
processingId.value = null;
}
};
const handlePayCredits = async (packageId: number) => {
if (processingId.value != null) return;
processingId.value = packageId;
try {
const orderRes = await apiCreateCreditOrder(packageId);
if (orderRes.code === SUCCESS_CODE && orderRes.data) {
const orderId = orderRes.data.id ?? orderRes.data;
await getCashierUrlAndRedirect(orderId as number);
} else {
uni.showToast({
title: t("Mobile.MySubscriptions.orderIdNotFound"),
icon: "none",
});
processingId.value = null;
}
} catch (e) {
uni.showToast({
title: t("Mobile.MySubscriptions.loadFailed"),
icon: "none",
});
processingId.value = null;
}
};
return {
processingId,
loading,
handlePaySubscription,
handlePayCredits,
};
}

View File

@@ -0,0 +1,178 @@
<template>
<view class="subscriptions-container">
<!-- 顶部导航栏 -->
<custom-nav-bar :title="t('Mobile.MySubscriptions.title')">
<template v-slot:left>
<view class="header-icon-box" @tap="jumpNavigateBack">
<text class="iconfont icon-a-Chevronleft"></text>
</view>
</template>
</custom-nav-bar>
<!-- 加载中 -->
<view v-if="pageLoading" class="loading-state">
<text class="loading-text">...</text>
</view>
<!-- 页面内容 -->
<scroll-view v-else scroll-y class="page-content">
<view class="page-scroll-content">
<!-- 积分概览 -->
<CreditsBreakdown
:summary="creditsSummary"
@add-purchase="handleOpenPurchase"
/>
<!-- 订阅套餐 -->
<PlanCards
:plans="plans"
:current-plan-id="currentPlanId"
:current-end-time="currentEndTime"
:current-plan-price="currentPlanPrice"
/>
<!-- 已订阅内容 -->
<SubscribedContent />
</view>
<!-- 积分购买弹窗 -->
<PurchaseModal ref="purchaseModalRef" />
</scroll-view>
</view>
</template>
<script setup lang="uts">
import type {
CreditSummaryInfo,
SystemSubscriptionPlan,
} from "@/subpackages/types/interfaces/subscription";
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
import {
apiGetCreditSummary,
apiGetMySubscription,
apiListSystemSubscriptionPlans,
} from "@/subpackages/servers/subscription";
import { jumpNavigateBack } from "@/utils/common";
import { setCurrentPageNavigationBarTitle } from "@/utils/system.uts";
import { useI18n } from "@/utils/i18n";
import { TENANT_CONFIG_INFO } from "@/constants/home.constants";
import type { TenantConfigInfo } from "@/types/interfaces/login";
import { apiTenantConfig } from "@/servers/account";
import CreditsBreakdown from "./components/credits-breakdown/credits-breakdown.uvue";
import PlanCards from "./components/plan-cards/plan-cards.uvue";
import SubscribedContent from "./components/subscribed-content/subscribed-content.uvue";
import PurchaseModal from "./components/purchase-modal/purchase-modal.uvue";
const { t } = useI18n();
const pageLoading = ref(true);
const creditsSummary = ref<CreditSummaryInfo | null>(null);
const plans = ref<SystemSubscriptionPlan[]>([]);
const currentPlanId = ref<number | null>(null);
const currentEndTime = ref<string | null>(null);
const currentPlanPrice = ref<number | null>(null);
const purchaseModalRef = ref<any>(null);
const fetchPageData = async () => {
pageLoading.value = true;
try {
const [summaryRes, subRes, plansRes] = await Promise.all([
apiGetCreditSummary(),
apiGetMySubscription({ bizType: "SYSTEM" }),
apiListSystemSubscriptionPlans(),
]);
if (summaryRes.code === SUCCESS_CODE && summaryRes.data) {
creditsSummary.value = summaryRes.data;
}
if (subRes.code === SUCCESS_CODE && subRes.data) {
const current = subRes.data.currentSubscription;
if (current) {
currentPlanId.value = current.planId;
currentEndTime.value = current.endTime;
currentPlanPrice.value = current.plan.price;
}
}
if (plansRes.code === SUCCESS_CODE && plansRes.data) {
plans.value = Array.isArray(plansRes.data) ? plansRes.data : [];
}
} catch (e) {
// ignore
} finally {
pageLoading.value = false;
}
};
const handleOpenPurchase = () => {
purchaseModalRef.value?.open();
};
onLoad(() => {
const checkAndFetchConfig = async () => {
// 1. 优先校验本地缓存
const tenantConfigInfoString = uni.getStorageSync(TENANT_CONFIG_INFO);
if (tenantConfigInfoString) {
try {
const tenantConfig = JSON.parse(
tenantConfigInfoString,
) as TenantConfigInfo;
if (tenantConfig.enableSubscription !== 1) {
uni.showToast({
title:
t("Mobile.MySubscriptions.notEnabled") || "订阅功能暂未开启",
icon: "none",
});
setTimeout(() => {
jumpNavigateBack();
}, 1500);
return;
}
} catch (error) {
console.error("解析租户配置失败:", error);
}
}
// 2. 实时拉取最新配置进行二次校验和本地同步
try {
const res = await apiTenantConfig();
if (res.code === SUCCESS_CODE && res.data != null) {
const latestConfig = res.data!;
uni.setStorageSync(TENANT_CONFIG_INFO, JSON.stringify(latestConfig));
if (latestConfig.enableSubscription !== 1) {
uni.showToast({
title:
t("Mobile.MySubscriptions.notEnabled") || "订阅功能暂未开启",
icon: "none",
});
setTimeout(() => {
jumpNavigateBack();
}, 1500);
return;
}
}
} catch (e) {
console.error("同步最新租户配置失败:", e);
}
// 3. 校验通过,加载页面
setCurrentPageNavigationBarTitle();
fetchPageData();
};
checkAndFetchConfig();
});
onShow(() => {
// 若页面已经初始化完毕(非初次加载),当页面重新显示时自动刷新最新数据
if (!pageLoading.value) {
fetchPageData();
}
});
</script>
<style lang="scss" scoped>
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,42 @@
.subscriptions-container {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f9f9f9;
overflow: hidden;
.header-icon-box {
display: flex;
align-items: center;
justify-content: center;
width: 56rpx;
height: 56rpx;
.iconfont {
font-size: 32rpx;
color: #666;
}
}
.page-content {
flex: 1;
height: 0;
}
.page-scroll-content {
padding: 20rpx 24rpx;
padding-bottom: 40rpx;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 200rpx 0;
.loading-text {
font-size: 28rpx;
color: #999;
}
}
}

View File

@@ -0,0 +1,64 @@
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
export function getPeriodUnit(period: string | null): string {
if (period == null) return "";
const map: Record<string, string> = {
MONTH: t("Mobile.MySubscriptions.perMonth"),
"1": t("Mobile.MySubscriptions.perMonth"),
QUARTER: t("Mobile.MySubscriptions.perQuarter"),
"3": t("Mobile.MySubscriptions.perQuarter"),
YEAR: t("Mobile.MySubscriptions.perYear"),
"12": t("Mobile.MySubscriptions.perYear"),
FOREVER: t("Mobile.MySubscriptions.perForever"),
};
return map[period] || "";
}
export function getFeeLabel(period: string | null): string {
if (period == null) return "";
const map: Record<string, string> = {
MONTH: t("Mobile.MySubscriptions.feeMonth"),
"1": t("Mobile.MySubscriptions.feeMonth"),
QUARTER: t("Mobile.MySubscriptions.feeQuarter"),
"3": t("Mobile.MySubscriptions.feeQuarter"),
YEAR: t("Mobile.MySubscriptions.feeYear"),
"12": t("Mobile.MySubscriptions.feeYear"),
};
return map[period] || "";
}
export function getPayTypeText(period: string | null): string {
if (period == null) return "";
const map: Record<string, string> = {
MONTH: t("Mobile.MySubscriptions.monthlyPayment"),
"1": t("Mobile.MySubscriptions.monthlyPayment"),
QUARTER: t("Mobile.MySubscriptions.quarterlyPayment"),
"3": t("Mobile.MySubscriptions.quarterlyPayment"),
YEAR: t("Mobile.MySubscriptions.yearlyPayment"),
"12": t("Mobile.MySubscriptions.yearlyPayment"),
};
return map[period] || "";
}
export function getStatusText(status: string): string {
const map: Record<string, string> = {
ACTIVE: t("Mobile.MySubscriptions.subscribing"),
EXPIRED: t("Mobile.MySubscriptions.expired"),
CANCELLED: t("Mobile.MySubscriptions.expired"),
};
return map[status] || "";
}
export function isActive(status: string): boolean {
return status === "ACTIVE";
}
export function isForever(period: string | null): boolean {
return period === "FOREVER";
}
export function formatMoney(amount: number): string {
return `¥${amount}`;
}

View File

@@ -0,0 +1,277 @@
<template>
<view class="temporary-session-container">
<custom-nav-bar :title="agentInfo?.name" show-back :icon="agentInfo?.icon">
</custom-nav-bar>
<!-- 主体区域:描述 -->
<scroll-view class="main-content" scroll-y>
<view class="main-content-inner">
<!-- 描述框 / 气泡样式 -->
<view class="agent-desc-box">
<text class="agent-desc-text">
{{agentInfo?.description || t("Mobile.TempSession.defaultDesc")}}
</text>
</view>
</view>
</scroll-view>
<!-- 底部输入栏,参考 chat-input-phone 样式实现 -->
<view
class="bottom-input-area"
v-if="showTextarea"
:style="{ paddingBottom: keyboardHeight > 0 ? '16rpx' : 'calc(32rpx + env(safe-area-inset-bottom))' }"
>
<view class="record-tip-container">
<view class="chat-input-container" :class="{ 'multi-line': isMultiLine }">
<textarea
class="input"
:class="{ 'input-text': inputValue?.length }"
placeholder-class="input-placeholder"
v-model="inputValue"
:auto-height="true"
@linechange="handleLineChange"
@keyboardheightchange="onKeyboardheightchange"
@confirm="handleSend"
:placeholder="t('Mobile.TempSession.inputPlaceholder')"
:show-confirm-bar="false"
:maxlength="-1"
:disable-default-padding="true"
></textarea>
<!-- 图标容器,当有输入内容时显示发送按钮 -->
<view class="icon-container" v-if="inputValue && inputValue.trim() !== ''">
<text class="iconfont icon-send icon-send-image" @click="handleSend" />
</view>
</view>
</view>
<!-- 开启键盘时,底部垫高安全区域 -->
<view class="keyboard-cover" :style="{ height: keyboardHeight + 'px' }"></view>
</view>
<!-- 登录弹窗 -->
<auth-login-popup ref="loginPopupRef" />
</view>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { onLoad, onReady } from "@dcloudio/uni-app";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
// 缓存数据模型
interface TempAgentInfo {
name: string;
icon: string;
description: string;
}
// 变量定义
const agentInfo = ref<TempAgentInfo | null>(null);
const inputValue = ref("");
const isMultiLine = ref<boolean>(false);
const showTextarea = ref<boolean>(false);
const keyboardHeight = ref<number>(0);
const loginPopupRef = ref<any>(null);
// 页面加载时读取缓存
onLoad(() => {
try {
const cachedData = uni.getStorageSync("temp_agent_info");
if (cachedData) {
agentInfo.value = JSON.parse(cachedData) as TempAgentInfo;
}
} catch (err) {
console.error("解析缓存出的智能体出错", err);
}
});
// 等待页面过场动画完成之后,再将真正的原生 textarea 暴露出来,避免跳转闪烁
onReady(() => {
setTimeout(() => {
showTextarea.value = true;
}, 500);
});
// 发送消息逻辑: 未登录用户拦截,弹登录弹框
const handleSend = () => {
if (!inputValue.value.trim()) {
uni.showToast({ title: t("Mobile.Page.inputRequired"), icon: "none" });
return;
}
// 在临时会话页只负责拦截并让用户登录
if (loginPopupRef.value) {
loginPopupRef.value.open();
}
};
// 监听输入框行数变化
const handleLineChange = (e: UniTextareaLineChangeEvent) => {
isMultiLine.value = e.detail.lineCount > 1;
};
// 键盘高度变化
const onKeyboardheightchange = (res: UniInputKeyboardHeightChangeEvent) => {
// 微信小程序
// #ifdef MP-WEIXIN
if (res.detail.height > 31) {
keyboardHeight.value = 30; // 这里的 30 对应简单输入框起跳补偿
} else {
keyboardHeight.value = 0;
}
// #endif
// 非微信小程序
// #ifndef MP-WEIXIN
keyboardHeight.value = res.detail.height;
// #endif
};
</script>
<style lang="scss" scoped>
.temporary-session-container {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
background-color: #ffffff;
overflow: hidden; /* 保证整体不超高 */
/* 内容展示区 */
.main-content {
flex: 1;
height: 0; /* 这是让 scroll-view flex 布局下工作并自我滚动的核心 */
.main-content-inner {
padding: 32rpx;
box-sizing: border-box;
}
.agent-desc-box {
align-self: flex-start;
padding: 24rpx 0;
margin-bottom: 40rpx;
.agent-desc-text {
font-size: 28rpx;
color: #333;
line-height: 1.5;
}
}
}
/* 底部输入区 */
.bottom-input-area {
padding: 16rpx 32rpx; /* 下边距由动态 style 计算接管 */
background-color: #fff;
border-radius: 16rpx 16rpx 0 0;
position: relative;
}
/* 仿 chat-input-phone 容器 */
.record-tip-container {
display: flex;
flex-direction: column;
width: 100%;
border-radius: 24rpx;
box-shadow:
0 9rpx 45rpx 0 rgba(0, 0, 0, 0.08),
0 2rpx 10rpx 0 rgba(0, 0, 0, 0.08),
0 0 3rpx 0 rgba(0, 0, 0, 0.08);
position: relative;
}
.chat-input-container {
width: 100%;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
min-height: 90rpx;
.input {
width: 100%; // 这里直接撑满,因为右边不常驻按钮
line-height: 44rpx;
max-height: 320rpx;
margin: 10rpx 0 10rpx 10rpx;
padding: 0 10rpx;
border: none;
font-size: 32rpx;
font-weight: 400;
color: #000;
box-sizing: border-box;
resize: none;
outline: none;
/* 给右侧图标预留空间 */
&.input-text {
width: 90%;
}
/* 隐藏滚动条 */
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
}
.input-placeholder {
color: rgba(0, 0, 0, 0.25);
font-size: 28rpx;
font-weight: 400;
}
.icon-container {
position: absolute;
right: 0rpx;
bottom: 0rpx;
display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 0;
z-index: 50;
background-color: transparent; /* 为了保持与框体融合 */
.iconfont {
padding: 15rpx;
}
.icon-send-image {
color: #5147ff !important;
font-size: 48rpx;
}
}
}
.chat-input-container.multi-line {
flex-direction: column;
align-items: stretch;
gap: 16rpx;
.input {
width: 87%;
line-height: 44rpx;
}
.icon-container {
align-self: flex-end;
flex-direction: row;
justify-content: flex-end;
}
}
.keyboard-cover {
// #ifdef APP-ANDROID
margin-bottom: env(safe-area-inset-bottom);
// #endif
// #ifndef APP-ANDROID
padding-bottom: env(safe-area-inset-bottom);
// #endif
}
}
</style>

View File

@@ -0,0 +1,232 @@
<template>
<!-- #ifdef H5 || WEB -->
<!-- 自定义导航栏 -->
<custom-nav-bar :title="displayPageTitle">
<template v-slot:left>
<text class="iconfont icon-a-Chevronleft" @tap="onBackClick"></text>
</template>
</custom-nav-bar>
<!-- #endif -->
<web-view
class="web-view page-container"
v-if="url.length != 0"
:src="url"
id="webview"
@error="handleWebviewError"
@load="handleWebviewLoad"
@message="handleWebviewMessage"
></web-view>
</template>
<script lang="uts" setup>
import { computed, ref } from "vue";
import { removeQueryCompat } from "@/utils/common.uts";
import { NO_LOGIN_CHECK_URL } from "@/constants/config";
import { apiUserTicketCreate } from "@/servers/agentDev";
import { SUCCESS_CODE } from "@/constants/codes.constants";
import { onAddToFavorites } from "@dcloudio/uni-app";
import { isHttpUrl } from "@/utils/common.uts";
import { isTabPage } from "@/utils/commonBusiness.uts";
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
const url = ref("");
const method = ref("");
const data_type = ref("");
const request_id = ref("");
const pageTitle = ref("");
const displayPageTitle = computed(
() => pageTitle.value || t("Mobile.Webview.defaultTitle"),
);
const getQueryParams = (url) => {
const queryString = url.split("?")[1] || "";
const pairs = queryString.split("&");
const params = {};
for (let pair of pairs) {
if (!pair) continue;
const [k, v] = pair.split("=");
params[decodeURIComponent(k)] = decodeURIComponent(v || "");
}
return params;
};
// 判断是否需要调用接口
const isNeedCallApi = computed(() => {
return !!(
method.value === "browser_navigate_page" &&
data_type.value &&
request_id.value
);
});
// WebView 加载错误处理
const handleWebviewError = (e: any) => {
console.error("WebView 加载错误:", e);
uni.showToast({
title: t("Mobile.Webview.loadFailed"),
icon: "none",
duration: 2000,
});
};
// WebView 加载完成
const handleWebviewLoad = (e: any) => {
// #ifdef MP-WEIXIN
if (isNeedCallApi.value) {
uni.showLoading({
title: t("Mobile.Webview.aiReading"),
mask: true,
});
setTimeout(() => {
uni.navigateBack({ delta: 1 });
uni.hideLoading();
}, 4000);
}
// #endif
};
/**
* WebView 消息处理
*
* ⚠️ 重要:微信小程序的 @message 不会实时触发!
* 触发时机:
* 1. 用户点击返回按钮(最常用)
* 2. 组件销毁时
* 3. 分享时
*
* 调试方法:
* 1. 网页发送消息后,点击小程序的返回按钮
* 2. 查看控制台是否有 "收到消息" 日志
* 3. 如果没有,检查网页端代码是否正确调用 wx.miniProgram.postMessage
*/
const handleWebviewMessage = (e: any) => {
// #ifdef MP-WEIXIN
// e.detail.data 是一个数组,包含所有 postMessage 发送的消息
const messages = e.detail.data;
if (isNeedCallApi.value && messages && messages.length > 0) {
// 处理最后一条消息 - 读取首页内容
uni.$emit("browser_navigate_page", {
method: method.value,
data_type: data_type.value,
html: messages[messages.length - 1].html,
request_id: request_id.value,
});
} else {
console.warn("消息数组为空");
}
// #endif
};
/**
* 获取分享链接
*/
const getSharingLink = () => {
const newUrl = removeQueryCompat(url.value, ["_ticket", "jump_type"]);
const andStr = newUrl.includes("?") ? "&" : "?";
const currentUrl = newUrl + andStr + "jump_type=outer";
return encodeURIComponent(currentUrl);
};
const onBackClick = () => {
const backUrl = uni.getStorageSync("backUrl");
// 首页是 tabbar 页面,必须使用 switchTab 跳转
const toUrl = backUrl && backUrl !== "/" ? backUrl : "/pages/index/index";
if (isTabPage(toUrl)) {
uni.switchTab({ url: toUrl });
} else {
uni.redirectTo({ url: toUrl });
}
uni.removeStorageSync("backUrl");
};
// 初始化加载
const onLoadInit = async (params: UTSJSONObject) => {
if (params.url) {
let currentUrl = decodeURIComponent(params.url);
const queryParams = getQueryParams(currentUrl);
// 设置页面标题
const title = queryParams.title || t("Mobile.Webview.defaultTitle");
pageTitle.value = title;
uni.setNavigationBarTitle({
title: title,
});
// #ifdef H5 || WEB
if (isHttpUrl(currentUrl)) {
url.value = currentUrl;
} else {
url.value = window.location.origin + currentUrl;
}
// #endif
// #ifdef MP-WEIXIN
// 判断当前url是否不需要登录检查
if (NO_LOGIN_CHECK_URL.includes(currentUrl)) {
url.value = currentUrl;
return;
}
if (queryParams.method) {
method.value = queryParams.method;
}
if (queryParams.data_type) {
data_type.value = queryParams.data_type;
}
if (queryParams.request_id) {
request_id.value = queryParams.request_id;
}
// 判断是否存在 _ticket
if (queryParams._ticket) {
url.value = currentUrl;
} else {
// 暂存当前url
globalThis.appConfig.redirectUrl = params.url; // 未解码
const ticketResult = await apiUserTicketCreate();
if (ticketResult.code === SUCCESS_CODE && ticketResult.data) {
const andStr = currentUrl.includes("?") ? "&" : "?";
url.value = currentUrl + andStr + `_ticket=${ticketResult.data}`;
}
}
// #endif
}
};
onLoad((params) => {
// 加载初始化数据
onLoadInit(params);
});
// #ifdef MP-WEIXIN
// 转发给朋友
onShareAppMessage(() => {
return {
title:
getQueryParams(url.value)?.["title"] || t("Mobile.Common.appName"),
path: "/subpackages/pages/webview/webview?url=" + getSharingLink(),
};
});
// 收藏
onAddToFavorites(() => {
return {
title:
getQueryParams(url.value)?.["title"] || t("Mobile.Common.appName"),
query: "url=" + getSharingLink(),
};
});
// #endif
</script>
<style lang="scss" scoped>
.web-view {
width: 100%;
}
</style>

View File

@@ -0,0 +1,52 @@
import request from "@/servers/useRequest";
import { RequestResponse } from "@/types/interfaces/request";
import { SandboxListResponse } from "@/types/interfaces/sandbox";
/**
* 获取沙盒列表
*/
export const apiGetSandboxList = (): Promise<
RequestResponse<SandboxListResponse>
> => {
return request<RequestResponse<SandboxListResponse>>({
url: "/api/sandbox/config/select/list",
method: "GET",
});
};
/**
* 更新选中的沙盒
*/
export const apiUpdateSelectedSandbox = (
agentId: string,
sandboxId: string,
): Promise<RequestResponse<null>> => {
return request<RequestResponse<null>>({
url: `/api/sandbox/config/selected/${agentId}/${sandboxId}`,
method: "POST",
});
};
/**
* 重启智能体
*/
export const apiRestartAgent = (
conversationId: number,
): Promise<RequestResponse<null>> => {
return request<RequestResponse<null>>({
url: `/api/computer/agent/stop/${conversationId}`,
method: "POST",
});
};
/**
* 重启容器
*/
export const apiRestartSandbox = (
conversationId: number,
): Promise<RequestResponse<null>> => {
return request<RequestResponse<null>>({
url: `/api/computer/pod/restart?cId=${conversationId}`,
method: "POST",
});
};

View File

@@ -0,0 +1,30 @@
import request from '@/servers/useRequest';
import { RequestResponse, Page } from '@/types/interfaces/request';
import { SkillListForAtParams, SkillInfoForAt } from '@/types/interfaces/skill';
// 全部技能列表
export function apiSkillListForAt(data: SkillListForAtParams) {
return request<RequestResponse<Page<SkillInfoForAt>>>({
url: '/api/published/skill/list-for-at',
method: 'POST',
data
});
}
// 最近使用的技能列表
export function apiSkillRecentlyUsedList(data: any) {
return request<RequestResponse<SkillInfoForAt[]>>({
url: '/api/published/skill/recentlyUsed/list',
method: 'POST',
data
});
}
// 已收藏的技能列表
export function apiSkillCollectList(data: any) {
return request<RequestResponse<SkillInfoForAt[]>>({
url: '/api/published/skill/collect/list',
method: 'POST',
data
});
}

View File

@@ -0,0 +1,92 @@
import { RequestResponse } from "@/types/interfaces/request";
import {
MySubscriptionData,
CreditSummaryInfo,
CreditPackageInfo,
CreditBatchItem,
SystemSubscriptionPlan,
CashierInfo,
CreditRecordInfo,
} from "@/subpackages/types/interfaces/subscription";
import request from "@/servers/useRequest";
export async function apiGetMySubscription(params: Record<string, string>) {
return request<RequestResponse<MySubscriptionData>>({
url: "/api/subscription/my",
method: "GET",
data: params,
});
}
export async function apiGetCreditSummary() {
return request<RequestResponse<CreditSummaryInfo>>({
url: "/api/credit/summary",
method: "GET",
});
}
export async function apiListSystemSubscriptionPlans() {
return request<RequestResponse<SystemSubscriptionPlan[]>>({
url: "/api/subscription/system/plans",
method: "GET",
});
}
export async function apiListCreditPackages() {
return request<RequestResponse<CreditPackageInfo[]>>({
url: "/api/credit/package/list",
method: "GET",
});
}
export async function apiCreateCreditOrder(packageId: number) {
return request<RequestResponse<any>>({
url: `/api/credit/order/create?packageId=${packageId}`,
method: "POST",
});
}
export async function apiGetCreditBatches(params: Record<string, string>) {
return request<RequestResponse<CreditBatchItem[]>>({
url: "/api/credit/batches",
method: "GET",
data: params,
});
}
export async function apiCreateSubscriptionOrder(planId: number) {
return request<RequestResponse<any>>({
url: `/api/subscription/order/create?planId=${planId}`,
method: "POST",
});
}
export async function apiGetOrderCashier(orderId: number, returnUrl?: string) {
const data: Record<string, any> = { orderId };
if (returnUrl != null) {
data["returnUrl"] = returnUrl;
}
return request<RequestResponse<CashierInfo>>({
url: "/api/bill/order/pay/cashier",
method: "GET",
data,
});
}
export async function apiGetCreditFlows(params: Record<string, any>) {
return request<RequestResponse<CreditRecordInfo[]>>({
url: "/api/credit/flows",
method: "GET",
data: params,
});
}
export async function apiGetAgentSubscriptionPlanList(
params: Record<string, any>,
) {
return request<RequestResponse<SystemSubscriptionPlan[]>>({
url: "/api/agent/plan/list",
method: "GET",
data: params,
});
}

View File

@@ -0,0 +1,4 @@
export enum ChatInputMethod {
Voice = 'voice',
Input = 'input',
}

View File

@@ -0,0 +1,150 @@
export enum BizTypeEnum {
System = 'SYSTEM',
Agent = 'AGENT',
Skill = 'SKILL',
}
export enum MySubscriptionStatusEnum {
Active = 'ACTIVE',
Expired = 'EXPIRED',
Cancelled = 'CANCELLED',
}
export enum MyPlanPeriodEnum {
Month = 'MONTH',
Quarter = 'QUARTER',
Year = 'YEAR',
Forever = 'FOREVER',
}
export enum CreditTypeEnum {
SUBSCRIPTION = 'SUBSCRIPTION',
PURCHASE = 'PURCHASE',
ACTIVITY = 'ACTIVITY',
MANUAL = 'MANUAL',
MODEL_CALL = 'MODEL_CALL',
AGENT_CALL = 'AGENT_CALL',
TOOL_CALL = 'TOOL_CALL',
MANUAL_DEDUCT = 'MANUAL_DEDUCT',
}
export interface CreditRecordInfo {
id: number;
userId: number;
batchNo: string;
creditType: string;
creditTypeName: string;
operationType: number; // 1-增加2-扣减
operationTypeName: string;
amount: number;
beforeAmount: number;
afterAmount: number;
bizNo: string;
created: string;
remark: string;
}
export interface MySubscriptionItem {
id: number | null;
planId: number;
planName: string;
bizType: string | null;
bizId: number | null;
bizName: string | null;
icon: string | null;
period: string | null;
startTime: string | null;
endTime: string | null;
status: string;
plan: {
id: number;
name: string;
description: string | null;
price: number;
firstPrice: number;
period: string;
creditAmount: number;
callLimitCount: number | null;
functionOnly: boolean | null;
dailyGiftCreditAmount: number | null;
isHot: boolean | null;
};
callUsedCount: number | null;
nextResetTime: string | null;
created: string | null;
modified: string | null;
}
export interface MySubscriptionData {
currentSubscription: MySubscriptionItem | null;
subscriptions: MySubscriptionItem[];
}
export interface CreditSummaryInfo {
totalCredit: number;
subscriptionCredit: number;
purchaseCredit: number;
activityCredit: number;
}
export interface CreditPackageInfo {
id: number;
packageName: string;
creditAmount: number;
price: number;
sort: number;
status: number;
period: string;
remark: string;
}
export interface CreditBatchItem {
id: number;
batchNo: string;
creditType: string;
totalAmount: number;
usedAmount: number;
remainAmount: number;
expireTime: string;
expired: boolean;
remark: string;
extra: {
price: number;
} | null;
created: string;
}
export interface SystemSubscriptionPlanItem {
name: string;
description: string;
icon: string;
selected: boolean;
}
export interface SystemSubscriptionPlanGroup {
name: string;
description: string;
groupType: string;
items: SystemSubscriptionPlanItem[];
}
export interface SystemSubscriptionPlan {
id: number;
name: string;
description: string;
price: number;
firstPrice: number;
period: string;
creditAmount: number;
callLimitCount: number;
functionOnly: boolean;
dailyGiftCreditAmount: number;
isHot: boolean;
status: number;
sort: number;
itemGroups: SystemSubscriptionPlanGroup[];
}
export interface CashierInfo {
cashierUrl: string;
}

View File

@@ -0,0 +1,113 @@
<template>
<view class="markdown-custom-process-group">
<view class="group-header" @tap="toggleExpanded">
<view class="header-left">
<text class="group-title">{{ getI18nText('Mobile.ThirdParty.MpHtml.toolCall') }}</text>
</view>
<view class="header-right">
<text class="process-count">{{ processCount + ' ' + getI18nText('Mobile.ThirdParty.MpHtml.itemCount') }}</text>
<text class="iconfont icon-a-Chevrondown expand-icon" :class="{ 'is-expanded': isExpanded }"></text>
</view>
</view>
<view v-if="isExpanded" class="group-content">
<slot />
</view>
</view>
</template>
<script>
import { t } from '@/utils/i18n'
export default {
name: 'MarkdownContainerGroup',
props: {
// 仅用于计算数量,不再直接渲染
childs: {
type: Array,
default: () => []
}
},
data() {
return {
isExpanded: false
}
},
computed: {
processCount() {
// 过滤掉可能的空白节点,支持原生 container 和 PC 端 HTML 标签名,同时排除隐藏的 Event 类型
return (this.childs || []).filter(n =>
(n.name === 'container' || n.name === 'markdown-custom-process') &&
n.attrs?.type !== 'Event'
).length
}
},
methods: {
getI18nText(key, params) {
return t(key, params)
},
toggleExpanded() {
this.isExpanded = !this.isExpanded
}
}
}
</script>
<style lang="scss" scoped>
.markdown-custom-process-group {
margin: 16rpx 0;
border-radius: 12rpx;
border: 1rpx solid rgba(0, 0, 0, 0.08);
background-color: #ffffff;
overflow: hidden;
.group-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 20rpx 24rpx;
background-color: rgba(0, 0, 0, 0.02);
.header-left {
display: flex;
flex-direction: row;
align-items: center;
.group-title {
font-size: 26rpx;
color: #333333;
}
}
.header-right {
display: flex;
flex-direction: row;
align-items: center;
gap: 12rpx;
.process-count {
font-size: 24rpx;
color: #8c8c8c;
}
.expand-icon {
font-size: 24rpx;
color: #8c8c8c;
transition: transform 0.3s ease;
&.is-expanded {
transform: rotate(180deg);
}
}
}
}
.group-content {
padding: 12rpx 24rpx;
border-top: 1rpx solid rgba(0, 0, 0, 0.05);
max-height: 500rpx;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}
</style>

View File

@@ -0,0 +1,648 @@
<template>
<view v-if="toolCall?.type !== 'Event'" class="tool-call-status">
<!-- Plan 类型直接显示任务列表 -->
<template v-if="isPlanType">
<!-- Plan 头部信息 -->
<view class="plan-header">
<view class="plan-info">
<text class="plan-name">{{
toolCall.name || getI18nText("Mobile.ThirdParty.MpHtml.executionPlan")
}}</text>
<!-- <view class="plan-status-display">
<view class="status-icon" :class="getStatusIconClass(toolCall.status)">
<text :class="getStatusIconType(toolCall.status)" :style="{color: getStatusIconColor(toolCall.status),fontSize: '32rpx'}"></text>
</view>
<text class="plan-status-text">{{ getStatusText(toolCall.status) }}</text>
</view> -->
</view>
<view class="plan-expand-icon" @tap="togglePlanExpanded">
<text class="iconfont" :class="planExpanded ? 'icon-MinusOutlined' : 'icon-Plus'"></text>
</view>
</view>
<!-- Plan 任务列表直接展示 -->
<view v-if="planExpanded && planTaskList.length > 0" class="plan-task-list">
<view v-for="(task, index) in planTaskList" :key="index" class="plan-task-item">
<!-- 字体图标实现的状态 -->
<view class="task-status-icon" :class="getTaskStatusClass(task.status)">
<text class="task-icon iconfont" :class="getTaskStatusIcon(task.status)"></text>
</view>
<text class="task-content">{{ task.content }}</text>
</view>
</view>
</template>
<!-- Plan 类型显示工具调用状态 -->
<template v-else>
<!-- 工具调用头部信息 -->
<view class="tool-header" @tap="toggleExpanded">
<view class="tool-info">
<text class="tool-name">{{ toolNameDisplay }}</text>
<view class="tool-status-display">
<view class="status-icon" :class="getStatusIconClass(toolCall.status)">
<text :class="getStatusIconType(toolCall.status)" :style="{color: getStatusIconColor(toolCall.status),fontSize: '32rpx'}"></text>
</view>
<text class="tool-status-text">{{ getStatusText(toolCall.status) }}</text>
</view>
</view>
<view class="tool-actions">
<view class="action-icon" @tap.stop.prevent="handleShowDetails(detailData)">
<text class="iconfont icon-ProfileOutlined"></text>
</view>
<!-- <view class="action-icon" @tap.stop.prevent="handleCopyToClipboard">
<text class="iconfont icon-Copy"></text>
</view> -->
<view v-if="isPageType(toolCall)" class="action-icon" @tap.stop.prevent="openPreviewPage(toolCall)">
<text class="iconfont icon-eye-open"></text>
</view>
</view>
</view>
<!-- 展开后的详细信息 调用参数/调用结果-->
<!-- <view v-if="expanded" class="tool-details-expanded">
<view v-if="detailData.params && Object.keys(detailData.params).length > 0" class="detail-section">
<view class="section-header">
<text class="section-title">{{
getI18nText("Mobile.ThirdParty.MpHtml.callArguments")
}}</text>
</view>
<view class="arguments-content">
<text class="arguments-text">{{ formatArguments(detailData.params) }}</text>
</view>
</view>
<view v-if="detailData.response" class="detail-section">
<view class="section-header">
<text class="section-title">{{
getI18nText("Mobile.ThirdParty.MpHtml.callResult")
}}</text>
</view>
<view class="output-content">
<text class="output-text">{{ formatResult(detailData.response) }}</text>
</view>
</view>
</view> -->
</template>
</view>
</template>
<script>
import uniIcons from '@/uni_modules/uni-icons/components/uni-icons/uni-icons.vue'
import { AgentComponentTypeEnum } from '@/types/enums/agent.uts';
import { getCurrentPageParams } from '@/utils/common';
import { getFileProxyUrlByConversationIdAndFilePath, jumpToFilePreviewPage } from '@/utils/system.uts';
import { t } from '@/utils/i18n';
export default {
name: 'Container',
components: {
uniIcons,
// toolDetailsModal
},
props: {
data: {
type: Object,
required: true
}
},
data() {
return {
// expanded: false,
planExpanded: true, // Plan 类型默认展开
toolCall: this.data
}
},
computed: {
// 判断是否为 Plan 类型
isPlanType() {
return this.toolCall.type === AgentComponentTypeEnum.Plan || this.toolCall.type === 'Plan'
},
// 格式化显示的工具名称,处理多行代码情况
toolNameDisplay() {
let name = this.toolCall.name || this.toolCall.type || '';
// 还原引号实体
name = name.replace(/&quot;/g, '"');
// 替换换行符为空格,防止换行符干扰单行省略号显示
return name.replace(/\n/g, ' ').trim();
},
// Plan 任务列表
planTaskList() {
const result = this.toolCall.result || {}
const data = result.data
if (Array.isArray(data)) {
return data
}
return []
},
detailData() {
const _result = this.toolCall.result || {}
return {
// 从结果中提取输入参数,如果没有则提供空对象
params: _result.input || {},
// 使用结果的 data 作为响应数据,如果没有则提供空对象
response: _result.data || null,
}
}
},
watch: {
data: {
handler(newData) {
this.toolCall = newData;
},
immediate: true
}
},
methods: {
getI18nText(key) {
return t(key)
},
// 打开页面首页
openPreviewPage(data) {
uni.$emit('page_preview_executing', data);
},
// 切换展开状态
async toggleExpanded() {
const result = this.data?.result;
// 打开预览页面
if (result?.kind === 'edit') {
const params = getCurrentPageParams();
const conversationId = params.conversationId;
const file_path = result?.input?.file_path;
const fileProxyUrl = await getFileProxyUrlByConversationIdAndFilePath(conversationId, file_path);
jumpToFilePreviewPage(conversationId, fileProxyUrl);
return;
}
// this.expanded = !this.expanded
},
// 切换 Plan 展开状态
togglePlanExpanded() {
this.planExpanded = !this.planExpanded
},
// 显示详情
showDetails() {
if (!this.toolCall.result) {
return uni.showToast({
icon: 'none',
title: t('Mobile.ThirdParty.MpHtml.emptyResult'),
})
}
// this.expanded = !this.expanded
},
// 处理显示详情点击(阻止冒泡)
handleShowDetails() {
uni.$emit('page_preview_detail', this.toolCall, this.detailData);
},
// 处理复制到剪贴板点击(阻止冒泡)
handleCopyToClipboard(event) {
event.stopPropagation()
this.copyToClipboard()
},
// 复制结果
copyResult() {
// 实现复制功能
console.log('复制结果')
},
// 复制到剪贴板
copyToClipboard() {
// 实现复制到剪贴板功能
uni.setClipboardData({
data: this.formatArguments(this.detailData),
success: () => {
uni.showToast({
title: t('Mobile.Common.copySuccess'),
})
},
fail: () => {
uni.showToast({
title: t('Mobile.Common.copyFailed'),
})
}
})
},
// 获取状态图标类型
getStatusIconType(status) {
const iconMap = {
'EXECUTING': 'icon-Loader', // 执行中显示加载图标
'FINISHED': 'icon-Check', // 已完成显示对勾
'FAILED': 'icon-waring' // 执行失败显示叉号
}
return ` iconfont ${iconMap[status] || 'icon-Check'}`
},
// 获取状态图标样式类
getStatusIconClass(status) {
const statusClassMap = {
'EXECUTING': 'status-icon-executing',
'FINISHED': 'status-icon-finished',
'FAILED': 'status-icon-failed'
}
return statusClassMap[status] || 'status-icon-default'
},
// 获取状态图标颜色
getStatusIconColor(status) {
const statusColorMap = {
'EXECUTING': '#1890ff',
'FINISHED': '#52c41a',
// 'FAILED': '#ff4d4f'
'FAILED': '#ffcb00'
}
return statusColorMap[status] || '#999'
},
// 获取状态文本
getStatusText(status) {
return '';
// const statusTextMap = {
// 'EXECUTING': '执行中',
// 'FINISHED': '已完成',
// 'FAILED': '执行失败'
// }
// return statusTextMap[status] || '未知状态'
},
// 获取任务状态样式类
getTaskStatusClass(status) {
const classMap = {
'completed': 'task-status-completed',
'pending': 'task-status-pending',
'in_progress': 'task-status-in-progress',
'failed': 'task-status-failed'
}
return classMap[status] || 'task-status-pending'
},
// 获取任务状态图标 (使用字体图标unicode)
getTaskStatusIcon(status) {
const iconMap = {
'pending': 'icon-BorderOutlined', // 待处理
'in_progress': 'icon-HourglassOutlined', // 进行中
'completed': 'icon-CheckSquareOutlined', // 已完成
'failed': 'icon-CloseSquareOutlined' // 失败
}
return iconMap[status] || 'icon-BorderOutlined'
},
// 格式化参数显示
formatArguments(args) {
try {
return JSON.stringify(args, null, 2)
} catch (error) {
return String(args)
}
},
// 格式化结果显示
formatResult(result) {
if (result && typeof result === 'object') {
try {
return JSON.stringify(result, null, 2)
} catch (error) {
return String(result)
}
}
return String(result)
},
// 格式化执行时间
formatExecutionTime(time) {
if (time < 1000) {
return `${time}ms`
} else if (time < 60000) {
return `${(time / 1000).toFixed(2)}s`
} else {
return `${(time / 60000).toFixed(2)}min`
}
},
isPageType(toolCall) {
return toolCall.type === AgentComponentTypeEnum.Page;
}
}
}
</script>
<style lang="scss" scoped>
.tool-call-status {
width: 100%;
margin: 16rpx 0;
border-radius: 16rpx;
background-color: rgba(12, 20, 102, 0.04);
overflow: hidden;
.iconfont {
font-size: 32rpx;
color: #333;
}
// Plan 类型样式
.plan-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 24rpx;
.plan-info {
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
gap: 16rpx;
.plan-name {
font-size: 28rpx;
font-weight: 600;
color: #333;
line-height: 40rpx;
}
.plan-status-display {
display: flex;
align-items: center;
flex-direction: row;
gap: 8rpx;
.status-icon {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&.status-icon-executing {
animation: spin 1s linear infinite;
}
}
.plan-status-text {
font-size: 24rpx;
color: #666;
line-height: 32rpx;
}
}
}
// .plan-expand-icon {
// .iconfont {
// font-size: 32rpx;
// color: #333;
// }
// }
}
// Plan 任务列表
.plan-task-list {
padding: 0 24rpx 24rpx;
border-top: 2rpx solid rgba(0, 0, 0, 0.06);
margin-top: 0;
.plan-task-item {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8rpx;
padding: 12rpx 0;
.task-status-icon {
flex-shrink: 0;
width: 32rpx;
height: 32rpx;
display: flex;
align-items: center;
justify-content: center;
margin-top: 4rpx;
.task-icon {
font-family: 'iconfont';
font-size: 32rpx;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #8c8c8c;
}
}
.task-content {
flex: 1;
font-size: 26rpx;
color: #262626;
line-height: 40rpx;
}
}
}
// 非 Plan 类型样式(原有样式)
.tool-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 24rpx;
.tool-info {
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
gap: 16rpx;
.tool-name {
font-size: 28rpx;
font-weight: 600;
color: #333;
line-height: 40rpx;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-status-display {
display: flex;
align-items: center;
flex-direction: row;
gap: 8rpx;
.status-icon {
width: 32rpx;
height: 32rpx;
// border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&.status-icon-executing {
animation: spin 1s linear infinite;
.iconfont {
color: #1890ff;
}
}
&.status-icon-finished {
color: #52c41a;
}
&.status-icon-failed {
color: #ff4d4f;
}
&.status-icon-default {
color: #999;
}
}
.tool-status-text {
font-size: 24rpx;
color: #666;
line-height: 32rpx;
margin-right: 6px;
}
}
}
.tool-actions {
display: flex;
align-items: center;
flex-direction: row;
gap: 16rpx;
.action-icon {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6rpx;
transition: background-color 0.3s ease;
cursor: pointer;
.iconfont {
// font-size: 32rpx;
font-weight: 400;
// color: #333;
line-height: 36rpx;
}
&:hover {
background-color: #f0f0f0;
}
}
}
}
.tool-details-expanded {
padding: 24rpx;
background-color: #f8f9fa;
border-top: 2rpx solid #f0f0f0;
.detail-section {
margin-bottom: 24rpx;
&:last-child {
margin-bottom: 0;
}
.section-header {
margin-bottom: 16rpx;
.section-title {
font-size: 26rpx;
font-weight: 600;
color: #333;
line-height: 36rpx;
}
}
.execute-id-content,
.arguments-content,
.output-content,
.error-content,
.execution-time {
padding: 16rpx;
background-color: #ffffff;
border-radius: 8rpx;
border: 2rpx solid #e9ecef;
.execute-id-text,
.arguments-text,
.output-text,
.error-text,
.time-text {
font-size: 24rpx;
line-height: 36rpx;
color: #495057;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
white-space: pre-wrap;
word-break: break-word;
}
}
&.error-section {
.error-content {
background-color: #fff5f5;
border-color: #ffccc7;
.error-text {
color: #cf1322;
}
}
}
}
}
// 加载动画
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 响应式设计
@media (max-width: 750rpx) {
.tool-header {
padding: 20rpx;
.tool-info {
.tool-name {
font-size: 26rpx;
}
.tool-status-display {
.tool-status-text {
font-size: 22rpx;
}
}
}
}
.tool-details-expanded {
padding: 20rpx;
}
}
}
</style>

View File

@@ -0,0 +1,6 @@
/**
* @fileoverview Container 插件
*/
function Container(vm) {}
export default Container;

View File

@@ -0,0 +1,38 @@
/**
* 按优先级从 processingList 中获取数据
* 优先级顺序FINISHED -> FAILED -> EXECUTING
* @param executeId 执行ID
* @param processingList 处理列表
* @returns 按优先级排序后的数据
*/
export const getProcessingDataByPriority = (executeId, processingList) => {
if (!processingList || !Array.isArray(processingList)) {
return {};
}
// 过滤出匹配 executeId 的项目
const matchedItems = processingList.filter(
(item) => item.executeId === executeId
);
if (matchedItems.length === 0) {
return {};
}
// 定义状态优先级(数字越小优先级越高)
const statusPriority = {
FINISHED: 1,
FAILED: 2,
EXECUTING: 3,
};
// 按优先级排序,优先级高的在前
const sortedItems = matchedItems.sort((a, b) => {
const priorityA = statusPriority[a.status] || 999;
const priorityB = statusPriority[b.status] || 999;
return priorityA - priorityB;
});
// 返回优先级最高的项目
return sortedItems[0] || {};
};

View File

@@ -0,0 +1,5 @@
export default {
copyByLongPress: true, // 是否需要长按代码块时显示复制代码内容菜单
showLanguageName: true, // 是否在代码块右上角显示语言的名称
showLineNumber: true, // 是否显示行号
};

View File

@@ -0,0 +1,157 @@
/**
* @fileoverview highlight 插件
* Include prismjs (https://prismjs.com)
*/
import prism from "./prism.min";
import installLanguages from "./prism-languages";
import config from "./config";
installLanguages(prism);
const getCopyButtonText = () => {
try {
const lang = String(uni.getStorageSync("I18N_LANG") || "zh-CN")
.toLowerCase()
.trim();
return lang.startsWith("en")
? "Copy code"
: "复制代码";
} catch (error) {
return "复制代码";
}
};
import Parser from "../parser";
function Highlight(vm) {
this.vm = vm;
}
Highlight.prototype.onParse = function (node, vm) {
if (node.name === "pre") {
if (vm.options.editable) {
node.attrs.class = (node.attrs.class || "") + " hl-pre";
return;
}
let i;
for (i = node.children.length; i--; ) {
if (node.children[i].name === "code") break;
}
if (i === -1) return;
const code = node.children[i];
let className = code.attrs.class + " " + node.attrs.class;
i = className.indexOf("language-");
if (i === -1) {
i = className.indexOf("lang-");
if (i === -1) {
className = "language-text";
i = 9;
} else {
i += 5;
}
} else {
i += 9;
}
let j;
for (j = i; j < className.length; j++) {
if (className[j] === " ") break;
}
const lang = className.substring(i, j);
if (code.children.length) {
const text = this.vm.getText(code.children).replace(/&amp;/g, "&")?.trim();
if (!text) return;
// #ifndef H5
// 非 H5 平台:将 pre 节点标记为 c=1并调用 vm.expose() 向上传播,
// 强制整个祖先链使用递归渲染,确保 pre 运在任何嵌套层级中都能被独立渲染
node.c = 1;
vm.expose();
// #endif
// #ifdef H5
if (node.c) {
node.c = undefined;
}
// #endif
if (prism.languages[lang]) {
code.children = new Parser(this.vm).parse(
// 加一层 pre 保留空白符
"<pre>" +
prism
.highlight(text, prism.languages[lang], lang)
.replace(/token /g, "hl-") +
"</pre>"
)[0].children;
}
node.attrs.class = "hl-pre";
code.attrs.class = "hl-code";
// Create hl-body wrapper
const hlBody = {
name: "div",
attrs: { class: "hl-body" },
children: []
};
// Create inner hl-container for stretching
const hlContainer = {
name: "div",
attrs: { class: "hl-container" },
children: []
};
if (config.showLineNumber) {
const line = text.split("\n").length;
const children = [];
for (let k = 1; k <= line; k++) {
children.push({
name: "div",
attrs: { class: "span" },
children: [{ type: "text", text: String(k) }],
});
}
hlContainer.children.push({
name: "div",
attrs: { class: "line-numbers-rows" },
children,
});
}
code.name = "div";
hlContainer.children.push(code);
hlBody.children.push(hlContainer);
node.children = [];
if (config.showLanguageName && vm.options.showHeader) {
const langChildren = [{ type: "text", text: lang }];
// #ifdef H5
// H5: 复制按钮放入 rich-text 内部,通过 srcElement.dataset 实现点击复制
langChildren.push({
name: "div",
attrs: {
class: "hl-copy-btn",
"data-content": text,
"data-action": "copy",
},
children: [{ type: "text", text: getCopyButtonText() }],
});
// #endif
// #ifndef H5
// 非 H5 平台:将代码文本存储到节点上,由 node.vue 渲染真实的可交互复制按钮
node._copyText = text;
// #endif
node.children.push({
name: "div",
attrs: {
class: "hl-language",
},
children: langChildren,
});
}
if (config.copyByLongPress) {
node.attrs.style += (node.attrs.style || "") + ";user-select:none";
// node.attrs["data-content"] = text;
// vm.expose();
}
node.children.push(hlBody);
}
}
};
export default Highlight;

View File

@@ -0,0 +1,96 @@
/**
* @fileoverview Additional Prism grammars for Java, Python, and TypeScript
*/
export default function (Prism) {
// JSON
if (!Prism.languages.json) {
Prism.languages.json = {
'property': {
pattern: /"(?:\\.|[^\\"\r\n])*"(?=\s*:)/,
greedy: true
},
'string': {
pattern: /"(?:\\.|[^\\"\r\n])*"(?!\s*:)/,
greedy: true
},
'comment': /\/\/.*|\/\*[\s\S]*?\*\//,
'number': /-?\d+\.?\d*(?:[eE][+-]?\d+)?/,
'punctuation': /[{}[\],]/,
'operator': /:/,
'boolean': /\b(?:true|false)\b/,
'null': /\bnull\b/
};
}
// Java
if (!Prism.languages.java) {
Prism.languages.java = Prism.languages.extend('clike', {
'keyword': /\b(?:abstract|continue|for|new|switch|assert|default|goto|package|synchronized|boolean|do|if|private|this|break|double|implements|protected|throw|byte|else|import|public|throws|case|enum|instanceof|return|transient|catch|extends|int|short|try|char|final|interface|static|void|class|finally|long|strictfp|volatile|const|float|native|super|while)\b/,
'number': /\b0b[01]+\b|\b0x[\da-f]*\.?[\da-f]+(?:p[+-]?\d+)?\b|(?:\b\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?[df]?\b/i,
'operator': {
pattern: /(^|[^.])(?:<<=?|>>>=?|>>=?|==?|!=|&&?|\|\|?|[+*\/%^-]=?|[?:~]|[-+]{1,2})/m,
lookbehind: true
}
});
Prism.languages.insertBefore('java', 'function', {
'annotation': {
alias: 'punctuation',
pattern: /(^|[^.])@\w+/,
lookbehind: true
}
});
}
// Python
if (!Prism.languages.python) {
Prism.languages.python = {
'comment': {
pattern: /(^|[^\\])#.*/,
lookbehind: true
},
'string-interpolation': {
pattern: /(?:f|rf|fr)(?:"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^\\"\r\n])*"|'(?:\\.|[^\\'\r\n])*')/i,
greedy: true,
inside: {
'interpolation': {
pattern: /((?:^|[^{])(?:{{)*){[\s\S]*?}/,
lookbehind: true,
inside: {
'rest': null
}
},
'string': /[\s\S]+/
}
},
'string': {
pattern: /(?:[bru]|rb|br)?(?:"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^\\"\r\n])*"|'(?:\\.|[^\\'\r\n])*')/i,
greedy: true
},
'function': {
pattern: /((?:^|\s)def\s+)\w+(?=\s*\()/g,
lookbehind: true
},
'class-name': {
pattern: /(\bclass\s+)\w+/i,
lookbehind: true
},
'keyword': /\b(?:as|assert|async|await|break|class|continue|def|del|elif|else|except|exec|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|print|raise|return|try|while|with|yield)\b/,
'builtin': /\b(?:bool|bytearray|bytes|complex|dict|float|frozenset|int|list|set|str|tuple|type|Any|Callable|Dict|List|Optional|Set|Tuple|Union)\b/,
'boolean': /\b(?:True|False|None)\b/,
'number': /\b(?:\d+\.?\d*(?:[eE][+-]?\d+)?|0x[\da-f]+|0b[01]+|0o[0-7]+)\b/i,
'operator': /[-+*/%=<>!&|^~]+/,
'punctuation': /[()\[\]{},.:;]/
};
Prism.languages.python['string-interpolation'].inside['interpolation'].inside.rest = Prism.languages.python;
}
// TypeScript
if (!Prism.languages.typescript) {
Prism.languages.typescript = Prism.languages.extend('javascript', {
'keyword': /\b(?:abstract|as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|is|keyof|let|module|namespace|new|null|of|package|private|protected|public|readonly|return|set|static|super|switch|this|throw|try|type|typeof|var|void|while|with|yield|any|boolean|constructor|declare|never|number|object|string|symbol|unknown)\b/,
'operator': /\.[.]{2}|=>|\+\+|--|\*\*|[-+*/%]=?|[!=]=?=?|&&?|\|\|?|[?^%]|~(?!=)/
});
Prism.languages.ts = Prism.languages.typescript;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,78 @@
/**
* @fileoverview latex 插件
* katex.min.js来源 https://github.com/rojer95/katex-mini
*/
import parse from "./katex.min";
function Latex() {}
Latex.prototype.onParse = function (node, vm) {
// $...$包裹的内容为latex公式
if (!vm.options.editable && node.type === "text" && node.text.includes("$")) {
const part = node.text.split(/(\${1,2})/);
const children = [];
let status = 0;
for (let i = 0; i < part.length; i++) {
if (i % 2 === 0) {
// 文本内容
if (part[i]) {
if (status === 0) {
children.push({
type: "text",
text: part[i],
});
} else {
if (status === 1) {
// 行内公式
const nodes = parse.default(part[i]);
children.push({
name: "span",
attrs: {},
l: "T",
f: "display:inline-block",
children: nodes,
});
} else {
// 块公式
const nodes = parse.default(part[i], {
displayMode: true,
});
children.push({
name: "div",
attrs: {
style: "text-align:center",
},
children: nodes,
});
}
}
}
} else {
// 分隔符
if (part[i] === "$" && part[i + 2] === "$") {
// 行内公式
status = 1;
part[i + 2] = "";
} else if (part[i] === "$$" && part[i + 2] === "$$") {
// 块公式
status = 2;
part[i + 2] = "";
} else {
if (part[i] && part[i] !== "$$") {
// 普通$符号
part[i + 1] = part[i] + part[i + 1];
}
// 重置状态
status = 0;
}
}
}
delete node.type;
delete node.text;
node.name = "span";
node.attrs = {};
node.children = children;
}
};
export default Latex;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,129 @@
/**
* @fileoverview markdown 插件
* Include markdown-it (https://github.com/markdown-it/markdown-it)
* Include github-markdown-css (https://github.com/sindresorhus/github-markdown-css)
*/
import MarkdownIt from "markdown-it";
import markdownItContainer from "markdown-it-container";
let index = 0;
// 初始化 markdown-it 实例
const md = new MarkdownIt({
html: true, // 允许 HTML 标签
linkify: true, // 自动将 URL 转换为链接
typographer: true, // 启用智能排版
breaks: false, // 将换行符转换为 <br> 标签
langPrefix: "language-",
});
md.use(markdownItContainer, "container", {
validate: function (params) {
return params.trim().match(/^\s*container\s+(.*)$/);
},
render: function (tokens, idx) {
var m = tokens[idx].info.trim().match(/^container\s+(.*)$/);
const data =
m?.[1]?.split(" ").reduce((acc, item) => {
const [key, value] = item.split("=");
// 防止 value 为 undefined 导致 replace 调用失败
if (key && value !== undefined) {
acc[key] = value.replace(/^"|"$/g, "");
}
return acc;
}, {}) || {};
if (tokens[idx].nesting === 1) {
// opening tag
return `<container data=${JSON.stringify(data)}>`;
} else {
// closing tag
return `</container>`;
}
},
});
md.use(markdownItContainer, "container-group", {
validate: function (params) {
return params.trim().match(/^\s*container-group\s*(.*)$/);
},
render: function (tokens, idx) {
if (tokens[idx].nesting === 1) {
// opening tag
return `<container-group>`;
} else {
// closing tag
return `</container-group>`;
}
},
});
md.use(markdownItContainer, "task-result", {
validate: function (params) {
return params.trim().match(/^task-result\b/);
},
render: function (tokens, idx) {
if (tokens[idx].nesting === 1) {
return "<task-result>";
} else {
return "</task-result>";
}
},
});
function Markdown(vm) {
this.vm = vm;
vm._ids = {};
}
Markdown.prototype.onUpdate = function (content) {
if (this.vm.markdown) {
// 归一化换行符,防止不同平台下识别失败
content = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// 解决中文标点符号后粗体失效的问题,增加零宽空格
content = content.replace(
/\*\*([^*]+)\*\*([,。!?;:])/g,
"**$1**&#8203;$2"
);
const result = md.render(content);
return result;
}
};
Markdown.prototype.onParse = function (node, vm) {
if (vm.options.markdown) {
// 中文 id 需要转换,否则无法跳转
if (
vm.options.useAnchor &&
node.attrs &&
/[\u4e00-\u9fa5]/.test(node.attrs.id)
) {
const id = "t" + index++;
this.vm._ids[node.attrs.id] = id;
node.attrs.id = id;
}
if (
[
"p",
"table",
"tr",
"th",
"td",
"blockquote",
"pre",
"hr",
"code",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
].includes(node.name)
) {
if (node.name === "table") node.f = "overflow-x: auto;display:block";
node.attrs.class = `md-${node.name} ${node.attrs.class || ""}`;
}
}
};
export default Markdown;

View File

@@ -0,0 +1,53 @@
/**
* @fileoverview markdown 插件
* Include marked (https://github.com/markedjs/marked)
* Include github-markdown-css (https://github.com/sindresorhus/github-markdown-css)
*/
import marked from "./marked.min";
let index = 0;
function Markdown(vm) {
this.vm = vm;
vm._ids = {};
}
Markdown.prototype.onUpdate = function (content) {
if (this.vm.markdown) {
// 解决中文标点符号后粗体失效的问题,增加零宽空格
content = content.replace(
/\*\*([^*]+)\*\*([,。!?;:])/g,
"**$1**&#8203;$2"
);
const result = marked(content);
return result;
}
};
Markdown.prototype.onParse = function (node, vm) {
if (vm.options.markdown) {
// 中文 id 需要转换,否则无法跳转
if (
vm.options.useAnchor &&
node.attrs &&
/[\u4e00-\u9fa5]/.test(node.attrs.id)
) {
const id = "t" + index++;
this.vm._ids[node.attrs.id] = id;
node.attrs.id = id;
}
if (
node.name === "p" ||
node.name === "table" ||
node.name === "tr" ||
node.name === "th" ||
node.name === "td" ||
node.name === "blockquote" ||
node.name === "pre" ||
node.name === "code"
) {
node.attrs.class = `md-${node.name} ${node.attrs.class || ""}`;
}
}
};
export default Markdown;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,523 @@
<template>
<view id="_root" :class="(selectable ? '_select ' : '') + '_root'" :style="containerStyle">
<slot v-if="!nodes[0]" />
<!-- #ifndef APP-PLUS-NVUE -->
<node v-else :childs="nodes" :processing-list="processingList"
:opts="[lazyLoad, loadingImg, errorImg, showImgMenu, selectable]" name="span" />
<!-- #endif -->
<!-- #ifdef APP-PLUS-NVUE -->
<web-view ref="web" src="/static/app-plus/mp-html/local.html" :style="'margin-top:-2px;height:' + height + 'px'"
@onPostMessage="_onMessage" />
<!-- #endif -->
</view>
</template>
<script>
/**
* mp-html v2.5.1
* @description 富文本组件
* @tutorial https://github.com/jin-yufeng/mp-html
* @property {String} container-style 容器的样式
* @property {String} content 用于渲染的 html 字符串
* @property {Boolean} copy-link 是否允许外部链接被点击时自动复制
* @property {String} domain 主域名,用于拼接链接
* @property {String} error-img 图片出错时的占位图链接
* @property {Boolean} lazy-load 是否开启图片懒加载
* @property {string} loading-img 图片加载过程中的占位图链接
* @property {Boolean} pause-video 是否在播放一个视频时自动暂停其他视频
* @property {Boolean} preview-img 是否允许图片被点击时自动预览
* @property {Boolean} scroll-table 是否给每个表格添加一个滚动层使其能单独横向滚动
* @property {Boolean | String} selectable 是否开启长按复制
* @property {Boolean} set-title 是否将 title 标签的内容设置到页面标题
* @property {Boolean} show-img-menu 是否允许图片被长按时显示菜单
* @property {Object} tag-style 标签的默认样式
* @property {Boolean | Number} use-anchor 是否使用锚点链接
* @event {Function} load dom 结构加载完毕时触发
* @event {Function} ready 所有图片加载完毕时触发
* @event {Function} imgtap 图片被点击时触发
* @event {Function} linktap 链接被点击时触发
* @event {Function} play 音视频播放时触发
* @event {Function} error 媒体加载出错时触发
*/
// #ifndef APP-PLUS-NVUE
import node from './node/node.vue'
// #endif
import Parser from './parser.js'
import markdownIt from './markdown-it/index.js'
import highlight from './highlight/index.js'
import latex from './latex/index.js'
import container from './container/index.js'
// import markdown from './markdown/index.js'
// const plugins = [markdown, highlight, latex,]
const plugins = [markdownIt, highlight, latex, container]
// #ifdef APP-PLUS-NVUE
const dom = weex.requireModule('dom')
// #endif
export default {
name: 'mp-html',
data() {
return {
nodes: [],
// #ifdef APP-PLUS-NVUE
height: 3
// #endif
}
},
props: {
markdown: Boolean,
containerStyle: {
type: String,
default: ''
},
content: {
type: String,
default: ''
},
processingList: {
type: Array,
default: () => []
},
conversationId: {
type: [String, Number],
default: ''
},
copyLink: {
type: [Boolean, String],
default: true
},
domain: String,
errorImg: {
type: String,
default: ''
},
lazyLoad: {
type: [Boolean, String],
default: false
},
loadingImg: {
type: String,
default: ''
},
pauseVideo: {
type: [Boolean, String],
default: true
},
previewImg: {
type: [Boolean, String],
default: true
},
scrollTable: [Boolean, String],
selectable: [Boolean, String],
setTitle: {
type: [Boolean, String],
default: true
},
showImgMenu: {
type: [Boolean, String],
default: true
},
tagStyle: Object,
useAnchor: [Boolean, Number],
showHeader: {
type: Boolean,
default: true
}
},
// #ifdef VUE3
emits: ['load', 'ready', 'imgtap', 'linktap', 'play', 'error'],
// #endif
// #ifndef APP-PLUS-NVUE
components: {
node
},
// #endif
watch: {
content(content) {
this.setContent(content)
}
},
created() {
this.plugins = []
for (let i = plugins.length; i--;) {
this.plugins.push(new plugins[i](this))
}
},
mounted() {
if (this.content && !this.nodes.length) {
this.setContent(this.content)
}
},
beforeDestroy() {
this._hook('onDetached')
},
methods: {
/**
* @description 将锚点跳转的范围限定在一个 scroll-view 内
* @param {Object} page scroll-view 所在页面的示例
* @param {String} selector scroll-view 的选择器
* @param {String} scrollTop scroll-view scroll-top 属性绑定的变量名
*/
in(page, selector, scrollTop) {
// #ifndef APP-PLUS-NVUE
if (page && selector && scrollTop) {
this._in = {
page,
selector,
scrollTop
}
}
// #endif
},
/**
* @description 锚点跳转
* @param {String} id 要跳转的锚点 id
* @param {Number} offset 跳转位置的偏移量
* @returns {Promise}
*/
navigateTo(id, offset) {
id = this._ids[decodeURI(id)] || id
return new Promise((resolve, reject) => {
if (!this.useAnchor) {
reject(Error('Anchor is disabled'))
return
}
offset = offset || parseInt(this.useAnchor) || 0
// #ifdef APP-PLUS-NVUE
if (!id) {
dom.scrollToElement(this.$refs.web, {
offset
})
resolve()
} else {
this._navigateTo = {
resolve,
reject,
offset
}
this.$refs.web.evalJs('uni.postMessage({data:{action:"getOffset",offset:(document.getElementById(' + id + ')||{}).offsetTop}})')
}
// #endif
// #ifndef APP-PLUS-NVUE
let deep = ' '
// #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO
deep = '>>>'
// #endif
const selector = uni.createSelectorQuery()
// #ifndef MP-ALIPAY
.in(this._in ? this._in.page : this)
// #endif
.select((this._in ? this._in.selector : '._root') + (id ? `${deep}#${id}` : '')).boundingClientRect()
if (this._in) {
selector.select(this._in.selector).scrollOffset()
.select(this._in.selector).boundingClientRect()
} else {
// 获取 scroll-view 的位置和滚动距离
selector.selectViewport().scrollOffset() // 获取窗口的滚动距离
}
selector.exec(res => {
if (!res[0]) {
reject(Error('Label not found'))
return
}
const scrollTop = res[1].scrollTop + res[0].top - (res[2] ? res[2].top : 0) + offset
if (this._in) {
// scroll-view 跳转
this._in.page[this._in.scrollTop] = scrollTop
} else {
// 页面跳转
uni.pageScrollTo({
scrollTop,
duration: 300
})
}
resolve()
})
// #endif
})
},
/**
* @description 获取文本内容
* @return {String}
*/
getText(nodes) {
let text = '';
(function traversal(nodes) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (node.type === 'text') {
text += node.text.replace(/&amp;/g, '&')
} else if (node.name === 'br') {
text += '\n'
} else {
// 块级标签前后加换行
const isBlock = node.name === 'p' || node.name === 'div' || node.name === 'tr' || node.name === 'li' || (node.name[0] === 'h' && node.name[1] > '0' && node.name[1] < '7')
if (isBlock && text && text[text.length - 1] !== '\n') {
text += '\n'
}
// 递归获取子节点的文本
if (node.children) {
traversal(node.children)
}
if (isBlock && text[text.length - 1] !== '\n') {
text += '\n'
} else if (node.name === 'td' || node.name === 'th') {
text += '\t'
}
}
}
})(nodes || this.nodes)
return text
},
/**
* @description 获取内容大小和位置
* @return {Promise}
*/
getRect() {
return new Promise((resolve, reject) => {
uni.createSelectorQuery()
// #ifndef MP-ALIPAY
.in(this)
// #endif
.select('#_root').boundingClientRect().exec(res => res[0] ? resolve(res[0]) : reject(Error('Root label not found')))
})
},
/**
* @description 暂停播放媒体
*/
pauseMedia() {
for (let i = (this._videos || []).length; i--;) {
this._videos[i].pause()
}
// #ifdef APP-PLUS
const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].pause()'
// #ifndef APP-PLUS-NVUE
let page = this.$parent
while (!page.$scope) page = page.$parent
page.$scope.$getAppWebview().evalJS(command)
// #endif
// #ifdef APP-PLUS-NVUE
this.$refs.web.evalJs(command)
// #endif
// #endif
},
/**
* @description 设置媒体播放速率
* @param {Number} rate 播放速率
*/
setPlaybackRate(rate) {
this.playbackRate = rate
for (let i = (this._videos || []).length; i--;) {
this._videos[i].playbackRate(rate)
}
// #ifdef APP-PLUS
const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].playbackRate=' + rate
// #ifndef APP-PLUS-NVUE
let page = this.$parent
while (!page.$scope) page = page.$parent
page.$scope.$getAppWebview().evalJS(command)
// #endif
// #ifdef APP-PLUS-NVUE
this.$refs.web.evalJs(command)
// #endif
// #endif
},
/**
* @description 设置内容
* @param {String} content html 内容
* @param {Boolean} append 是否在尾部追加
*/
setContent(content, append) {
if (!append || !this.imgList) {
this.imgList = []
}
const nodes = new Parser(this).parse(content)
// #ifdef APP-PLUS-NVUE
if (this._ready) {
this._set(nodes, append)
}
// #endif
this.$set(this, 'nodes', append ? (this.nodes || []).concat(nodes) : nodes)
// #ifndef APP-PLUS-NVUE
this._videos = []
this.$nextTick(() => {
this._hook('onLoad')
this.$emit('load')
})
if (this.lazyLoad || this.imgList._unloadimgs < this.imgList.length / 2) {
// 设置懒加载,每 350ms 获取高度,不变则认为加载完毕
let height = 0
const callback = rect => {
if (!rect || !rect.height) rect = {}
// 350ms 总高度无变化就触发 ready 事件
if (rect.height === height) {
this.$emit('ready', rect)
} else {
height = rect.height
setTimeout(() => {
this.getRect().then(callback).catch(callback)
}, 350)
}
}
this.getRect().then(callback).catch(callback)
} else {
// 未设置懒加载,等待所有图片加载完毕
if (!this.imgList._unloadimgs) {
this.getRect().then(rect => {
this.$emit('ready', rect)
}).catch(() => {
this.$emit('ready', {})
})
}
}
// #endif
},
/**
* @description 调用插件钩子函数
*/
_hook(name) {
for (let i = plugins.length; i--;) {
if (this.plugins[i][name]) {
this.plugins[i][name]()
}
}
},
// #ifdef APP-PLUS-NVUE
/**
* @description 设置内容
*/
_set(nodes, append) {
this.$refs.web.evalJs('setContent(' + JSON.stringify(nodes).replace(/%22/g, '') + ',' + JSON.stringify([this.containerStyle.replace(/(?:margin|padding)[^;]+/g, ''), this.errorImg, this.loadingImg, this.pauseVideo, this.scrollTable, this.selectable]) + ',' + append + ')')
},
/**
* @description 接收到 web-view 消息
*/
_onMessage(e) {
const message = e.detail.data[0]
switch (message.action) {
// web-view 初始化完毕
case 'onJSBridgeReady':
this._ready = true
if (this.nodes) {
this._set(this.nodes)
}
break
// 内容 dom 加载完毕
case 'onLoad':
this.height = message.height
this._hook('onLoad')
this.$emit('load')
break
// 所有图片加载完毕
case 'onReady':
this.getRect().then(res => {
this.$emit('ready', res)
}).catch(() => {
this.$emit('ready', {})
})
break
// 总高度发生变化
case 'onHeightChange':
this.height = message.height
break
// 图片点击
case 'onImgTap':
this.$emit('imgtap', message.attrs)
if (this.previewImg) {
uni.previewImage({
current: parseInt(message.attrs.i),
urls: this.imgList
})
}
break
// 链接点击
case 'onLinkTap': {
const href = message.attrs.href
this.$emit('linktap', message.attrs)
if (href) {
// 锚点跳转
if (href[0] === '#') {
if (this.useAnchor) {
dom.scrollToElement(this.$refs.web, {
offset: message.offset
})
}
} else if (href.includes('://')) {
// 打开外链
if (this.copyLink) {
plus.runtime.openWeb(href)
}
} else {
uni.navigateTo({
url: href,
fail() {
uni.switchTab({
url: href
})
}
})
}
}
break
}
case 'onPlay':
this.$emit('play')
break
// 获取到锚点的偏移量
case 'getOffset':
if (typeof message.offset === 'number') {
dom.scrollToElement(this.$refs.web, {
offset: message.offset + this._navigateTo.offset
})
this._navigateTo.resolve()
} else {
this._navigateTo.reject(Error('Label not found'))
}
break
// 点击
case 'onClick':
this.$emit('tap')
this.$emit('click')
break
// 出错
case 'onError':
this.$emit('error', {
source: message.source,
attrs: message.attrs
})
}
}
// #endif
}
}
</script>
<style>
/* #ifndef APP-PLUS-NVUE */
/* 根节点样式 */
._root {
padding: 1px 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
color: rgb(64, 64, 64);
white-space: pre-wrap;
}
/* 长按复制 */
._select {
user-select: text;
}
/* #endif */
</style>

View File

@@ -0,0 +1,893 @@
<template>
<view
:id="attrs.id"
:class="'_block _' + name + ' ' + attrs.class"
:style="attrs.style"
>
<block v-for="(n, i) in childs" v-bind:key="i">
<!-- 图片 -->
<!-- 占位图 -->
<image
v-if="
n.name === 'img' && !n.t && ((opts[1] && !ctrl[i]) || ctrl[i] < 0)
"
class="_img"
:style="n.attrs.style"
:src="ctrl[i] < 0 ? opts[2] : opts[1]"
mode="widthFix"
/>
<!-- 显示图片 -->
<!-- #ifdef H5 || (APP-PLUS && VUE2) -->
<img
v-if="n.name === 'img'"
:id="n.attrs.id"
:class="'_img ' + n.attrs.class"
:style="(ctrl[i] === -1 ? 'display:none;' : '') + n.attrs.style"
:src="n.attrs.src || (ctrl.load ? n.attrs['data-src'] : '')"
:data-i="i"
@load="imgLoad"
@error="mediaError"
@tap.stop="imgTap"
@longpress="imgLongTap"
/>
<!-- #endif -->
<!-- #ifndef H5 || (APP-PLUS && VUE2) -->
<!-- 表格中的图片,使用 rich-text 防止大小不正确 -->
<rich-text
v-if="n.name === 'img' && n.t"
:style="'display:' + n.t"
:nodes="[
{
attrs: { style: n.attrs.style || '', src: n.attrs.src },
name: 'img',
},
]"
:data-i="i"
@tap.stop="imgTap"
/>
<!-- #endif -->
<!-- #ifdef APP-HARMONY -->
<image
v-else-if="n.name === 'img'"
:id="n.attrs.id"
:class="'_img ' + n.attrs.class"
:style="
(ctrl[i] === -1 ? 'display:none;' : '') +
'width:' +
ctrl[i] +
'px;' +
n.attrs.style
"
:src="n.attrs.src || (ctrl.load ? n.attrs['data-src'] : '')"
:mode="!n.h ? 'widthFix' : !n.w ? 'heightFix' : n.m || 'scaleToFill'"
:data-i="i"
@load="imgLoad"
@error="mediaError"
@tap.stop="imgTap"
@longpress="imgLongTap"
/>
<!-- #endif -->
<!-- #ifndef H5 || APP-PLUS || MP-KUAISHOU -->
<image
v-else-if="n.name === 'img'"
:id="n.attrs.id"
:class="'_img ' + n.attrs.class"
:style="
(ctrl[i] === -1 ? 'display:none;' : '') +
'width:' +
(ctrl[i] || 1) +
'px;height:1px;' +
n.attrs.style
"
:src="n.attrs.src"
:mode="!n.h ? 'widthFix' : !n.w ? 'heightFix' : n.m || 'scaleToFill'"
:lazy-load="opts[0]"
:webp="n.webp"
:show-menu-by-longpress="opts[3] && !n.attrs.ignore"
:image-menu-prevent="!opts[3] || n.attrs.ignore"
:data-i="i"
@load="imgLoad"
@error="mediaError"
@tap.stop="imgTap"
@longpress="imgLongTap"
/>
<!-- #endif -->
<!-- #ifdef MP-KUAISHOU -->
<image
v-else-if="n.name === 'img'"
:id="n.attrs.id"
:class="'_img ' + n.attrs.class"
:style="(ctrl[i] === -1 ? 'display:none;' : '') + n.attrs.style"
:src="n.attrs.src"
:lazy-load="opts[0]"
:data-i="i"
@load="imgLoad"
@error="mediaError"
@tap.stop="imgTap"
></image>
<!-- #endif -->
<!-- #ifdef APP-PLUS && VUE3 -->
<image
v-else-if="n.name === 'img'"
:id="n.attrs.id"
:class="'_img ' + n.attrs.class"
:style="
(ctrl[i] === -1 ? 'display:none;' : '') +
'width:' +
(ctrl[i] || 1) +
'px;' +
n.attrs.style
"
:src="n.attrs.src || (ctrl.load ? n.attrs['data-src'] : '')"
:mode="!n.h ? 'widthFix' : !n.w ? 'heightFix' : n.m || ''"
:data-i="i"
@load="imgLoad"
@error="mediaError"
@tap.stop="imgTap"
@longpress="imgLongTap"
/>
<!-- #endif -->
<!-- 文本 -->
<!-- #ifdef MP-WEIXIN -->
<text
v-else-if="n.text"
:user-select="opts[4] == 'force' && isiOS"
decode
>{{ n.text }}</text
>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN || MP-BAIDU || MP-ALIPAY || MP-TOUTIAO -->
<text v-else-if="n.text" decode>{{ n.text }}</text>
<!-- #endif -->
<text v-else-if="n.name === 'br'">{{ "\n" }}</text>
<!-- 链接 -->
<view
v-else-if="n.name === 'a'"
:id="n.attrs.id"
:class="(n.attrs.href ? '_a ' : '') + n.attrs.class"
hover-class="_hover"
:style="'display:inline;' + n.attrs.style"
:data-i="i"
@tap.stop="linkTap"
>
<node
name="span"
:childs="n.children"
:opts="opts"
style="display: inherit"
/>
</view>
<!-- 视频 -->
<!-- #ifdef APP-PLUS -->
<view
v-else-if="n.html"
:id="n.attrs.id"
:class="'_video ' + n.attrs.class"
:style="n.attrs.style"
v-html="n.html"
:data-i="i"
@vplay.stop="play"
/>
<!-- #endif -->
<!-- #ifndef APP-PLUS -->
<video
v-else-if="n.name === 'video'"
:id="n.attrs.id"
:class="n.attrs.class"
:style="n.attrs.style"
:autoplay="n.attrs.autoplay"
:controls="n.attrs.controls"
:loop="n.attrs.loop"
:muted="n.attrs.muted"
:object-fit="n.attrs['object-fit']"
:poster="n.attrs.poster"
:src="n.src[ctrl[i] || 0]"
:data-i="i"
@play="play"
@error="mediaError"
/>
<!-- #endif -->
<!-- #ifdef H5 || APP-PLUS -->
<iframe
v-else-if="n.name === 'iframe'"
:style="n.attrs.style"
:allowfullscreen="n.attrs.allowfullscreen"
:frameborder="n.attrs.frameborder"
:src="n.attrs.src"
/>
<embed
v-else-if="n.name === 'embed'"
:style="n.attrs.style"
:src="n.attrs.src"
/>
<!-- #endif -->
<!-- #ifndef MP-TOUTIAO || ((H5 || APP-PLUS) && VUE3) -->
<!-- 音频 -->
<audio
v-else-if="n.name === 'audio'"
:id="n.attrs.id"
:class="n.attrs.class"
:style="n.attrs.style"
:author="n.attrs.author"
:controls="n.attrs.controls"
:loop="n.attrs.loop"
:name="n.attrs.name"
:poster="n.attrs.poster"
:src="n.src[ctrl[i] || 0]"
:data-i="i"
@play="play"
@error="mediaError"
/>
<!-- #endif -->
<view
v-else-if="(n.name === 'table' && n.c) || n.name === 'li'"
:id="n.attrs.id"
:class="'_' + n.name + ' ' + n.attrs.class"
:style="n.attrs.style"
>
<node v-if="n.name === 'li'" :childs="n.children" :opts="opts" />
<view
v-else
v-for="(tbody, x) in n.children"
v-bind:key="x"
:class="'_' + tbody.name + ' ' + tbody.attrs.class"
:style="tbody.attrs.style"
>
<node
v-if="tbody.name === 'td' || tbody.name === 'th'"
:childs="tbody.children"
:opts="opts"
/>
<block v-else v-for="(tr, y) in tbody.children" v-bind:key="y">
<view
v-if="tr.name === 'td' || tr.name === 'th'"
:class="'_' + tr.name + ' ' + tr.attrs.class"
:style="tr.attrs.style"
>
<node :childs="tr.children" :opts="opts" />
</view>
<view
v-else
:class="'_' + tr.name + ' ' + tr.attrs.class"
:style="tr.attrs.style"
>
<view
v-for="(td, z) in tr.children"
v-bind:key="z"
:class="'_' + td.name + ' ' + td.attrs.class"
:style="td.attrs.style"
>
<node :childs="td.children" :opts="opts" />
</view>
</view>
</block>
</view>
</view>
<markdown-container
v-else-if="n.name == 'container'"
:data="getRenderData(n.attrs.data)"
data-source="markdown-container"
/>
<!-- markdown-custom-process 工具调用组件 -->
<markdown-container
v-else-if="n.name == 'markdown-custom-process'"
:data="getRenderData(n.attrs)"
data-source="markdown-container"
/>
<markdown-container-group
v-else-if="n.name == 'container-group' || n.name == 'markdown-custom-process-group'"
:childs="n.children"
>
<node :childs="n.children" :opts="opts" :processing-list="processingList" />
</markdown-container-group>
<!-- task-result 任务结果组件 -->
<task-result
v-else-if="n.name === 'task-result'"
:data="n"
:conversation-id="getConversationId()"
/>
<!-- 富文本 -->
<!-- #ifdef H5 || ((MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE2) -->
<rich-text
v-else-if="!n.c && (n.l || !handler.isInline(n.name, n.attrs.style))"
:id="n.attrs.id"
:style="n.f"
:user-select="opts[4]"
:nodes="[n]"
@tap="copyCode"
/>
<!-- #endif -->
<!-- #ifndef H5 || ((MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE2) -->
<!-- hl-language 头部节点:将 rich-text 和原生复制按钮并排在同一行,避免给对布局的依赖 -->
<view
v-else-if="
!n.c && n.attrs && n.attrs.class === 'hl-language' && copyText
"
class="hl-language-native"
>
<text class="hl-language-name">{{
(n.children && n.children[0] && n.children[0].text) || ""
}}</text>
<view class="hl-copy-btn-native" @tap.stop="doCopyText(copyText)">
<text class="hl-copy-btn-text">{{
getI18nText("Mobile.ThirdParty.MpHtml.copyCode")
}}</text>
</view>
</view>
<rich-text
v-else-if="!n.c"
:id="n.attrs.id"
:style="'display:inline;' + n.f"
:class="
n.name === 'div' && n.attrs?.class === 'event'
? 'div-event-style'
: ''
"
:preview="false"
:selectable="opts[4]"
:user-select="opts[4]"
:nodes="[n]"
@tap="handleCurrentTap(n)"
/>
<!-- #endif -->
<!-- 继续递归 -->
<view
v-else-if="n.c === 2"
:id="n.attrs.id"
:class="'_block _' + n.name + ' ' + n.attrs.class"
:style="n.f + ';' + n.attrs.style"
>
<node
v-for="(n2, j) in n.children"
v-bind:key="j"
:style="n2.f"
:name="n2.name"
:attrs="n2.attrs"
:childs="n2.children"
:opts="opts"
:copy-text="n2._copyText"
/>
</view>
<node
v-else
:style="n.f"
:name="n.name"
:attrs="n.attrs"
:childs="n.children"
:opts="opts"
:copy-text="n._copyText"
/>
</block>
</view>
</template>
<script module="handler" lang="wxs">
// 行内标签列表
var inlineTags = {
abbr: true,
b: true,
big: true,
code: true,
del: true,
em: true,
i: true,
ins: true,
label: true,
q: true,
small: true,
span: true,
strong: true,
sub: true,
sup: true,
};
/**
* @description 判断是否为行内标签
*/
module.exports = {
isInline: function (tagName, style) {
return (
inlineTags[tagName] || (style || "").indexOf("display:inline") !== -1
);
},
};
</script>
<script>
import markdownContainer from '../container/container.vue'
import markdownContainerGroup from '../container/container-group.vue'
import taskResult from '../task-result/task-result.vue'
import { getProcessingDataByPriority } from '../container/utils'
import node from './node'
import { t } from '@/utils/i18n'
export default {
name: 'node',
options: {
// #ifdef MP-WEIXIN
virtualHost: true,
// #endif
// #ifdef MP-TOUTIAO
addGlobalClass: false
// #endif
},
data () {
return {
ctrl: {},
root: null, // Initialize root to prevent "not defined" warning during render
// #ifdef MP-WEIXIN
isiOS: uni.getDeviceInfo().system.includes('iOS')
// #endif
}
},
props: {
name: String,
processingList: Array,
attrs: {
type: Object,
default () {
return {}
}
},
childs: Array,
opts: Array,
copyText: String
},
components: {
markdownContainer,
markdownContainerGroup,
taskResult,
// #ifndef ((H5 || APP-PLUS) && VUE3) || APP-HARMONY
node
// #endif
},
mounted () {
this.$nextTick(() => {
for (this.root = this.$parent; this.root.$options.name !== 'mp-html'; this.root = this.root.$parent);
})
// #ifdef H5 || APP-PLUS
if (this.opts[0]) {
let i
for (i = this.childs.length; i--;) {
if (this.childs[i].name === 'img') break
}
if (i !== -1) {
this.observer = uni.createIntersectionObserver(this).relativeToViewport({
top: 500,
bottom: 500
})
this.observer.observe('._img', res => {
if (res.intersectionRatio) {
this.$set(this.ctrl, 'load', 1)
this.observer.disconnect()
}
})
}
}
// #endif
},
beforeDestroy () {
// #ifdef H5 || APP-PLUS
if (this.observer) {
this.observer.disconnect()
}
// #endif
},
methods:{
getI18nText (key) {
return t(key)
},
showTextToast (title) {
uni.showToast({
title,
icon: 'none'
})
},
/**
* 获取会话ID用于task-result等组件
*/
getConversationId() {
// 尝试从根组件获取会话ID
if (this.root && this.root.conversationId) {
return this.root.conversationId
}
// 尝试从mp-html组件获取
for (let parent = this.$parent; parent; parent = parent.$parent) {
if (parent.conversationId) {
return parent.conversationId
}
}
return ''
},
getRenderData (data: any) {
if(!data) {
return {}
}
if(typeof data === 'string') {
try {
data = JSON.parse(data)
} catch (e) {
console.warn('[mp-html/node] getRenderData JSON.parse error:', e, 'data:', data)
return {} // Return empty object on parse failure
}
}
// Ensure data is an object before spreading
if (typeof data !== 'object' || data === null) {
return {}
}
const result = {
...data,
// 在 HTML 解析过程中mp-html 的解析器会将属性名自动转为全小写,也就是 executeid
...getProcessingDataByPriority(data.executeId || data.executeid, this.processingList)
}
return result
},
// #ifdef MP-WEIXIN
toJSON () { return this },
// #endif
/**
* @description 播放视频事件
* @param {Event} e
*/
play (e) {
const i = e.currentTarget.dataset.i
const node = this.childs[i]
this.root.$emit('play', {
source: node.name,
attrs: {
...node.attrs,
src: node.src[this.ctrl[i] || 0]
}
})
// #ifndef APP-PLUS
if (this.root.pauseVideo) {
let flag = false
const id = e.target.id
for (let i = this.root._videos.length; i--;) {
if (this.root._videos[i].id === id) {
flag = true
} else {
this.root._videos[i].pause() // 自动暂停其他视频
}
}
// 将自己加入列表
if (!flag) {
const ctx = uni.createVideoContext(id
// #ifndef MP-BAIDU
, this
// #endif
)
ctx.id = id
if (this.root.playbackRate) {
ctx.playbackRate(this.root.playbackRate)
}
this.root._videos.push(ctx)
}
}
// #endif
},
/**
* @description 图片点击事件
* @param {Event} e
*/
imgTap (e) {
const node = this.childs[e.currentTarget.dataset.i]
if (node.a) {
this.linkTap(node.a)
return
}
if (node.attrs.ignore) return
// #ifdef H5 || APP-PLUS
node.attrs.src = node.attrs.src || node.attrs['data-src']
// #endif
// #ifndef APP-HARMONY
this.root.$emit('imgtap', node.attrs)
// #endif
// #ifdef APP-HARMONY
this.root.$emit('imgtap', {
...node.attrs
})
// #endif
// 自动预览图片
if (this.root.previewImg) {
uni.previewImage({
// #ifdef MP-WEIXIN
showmenu: this.root.showImgMenu,
// #endif
// #ifdef MP-ALIPAY
enablesavephoto: this.root.showImgMenu,
enableShowPhotoDownload: this.root.showImgMenu,
// #endif
current: parseInt(node.attrs.i),
urls: this.root.imgList
})
}
},
/**
* @description 图片长按
*/
imgLongTap (e) {
// #ifdef APP-PLUS
const attrs = this.childs[e.currentTarget.dataset.i].attrs
if (this.opts[3] && !attrs.ignore) {
uni.showActionSheet({
itemList: ['保存图片'],
success: () => {
const save = path => {
uni.saveImageToPhotosAlbum({
filePath: path,
success: () => {
this.showTextToast(t('Mobile.ThirdParty.MpHtml.saveSuccess'))
}
})
}
if (this.root.imgList[attrs.i].startsWith('http')) {
uni.downloadFile({
url: this.root.imgList[attrs.i],
success: res => save(res.tempFilePath)
})
} else {
save(this.root.imgList[attrs.i])
}
}
})
}
// #endif
},
/**
* @description 图片加载完成事件
* @param {Event} e
*/
imgLoad (e) {
const i = e.currentTarget.dataset.i
/* #ifndef H5 || (APP-PLUS && VUE2) */
if (!this.childs[i].w) {
// 设置原宽度
this.$set(this.ctrl, i, e.detail.width)
} else /* #endif */ if ((this.opts[1] && !this.ctrl[i]) || this.ctrl[i] === -1) {
// 加载完毕,取消加载中占位图
this.$set(this.ctrl, i, 1)
}
this.checkReady()
},
/**
* @description 检查是否所有图片加载完毕
*/
checkReady () {
if (this.root && !this.root.lazyLoad) {
this.root._unloadimgs -= 1
if (!this.root._unloadimgs) {
setTimeout(() => {
this.root.getRect().then(rect => {
this.root.$emit('ready', rect)
}).catch(() => {
this.root.$emit('ready', {})
})
}, 350)
}
}
},
/**
* @description 链接点击事件
* @param {Event} e
*/
linkTap (e) {
const node = e.currentTarget ? this.childs[e.currentTarget.dataset.i] : {}
const attrs = node.attrs || e
const href = attrs.href
this.root.$emit('linktap', Object.assign({
innerText: this.root.getText(node.children || []) // 链接内的文本内容
}, attrs))
if (href) {
if (href[0] === '#') {
// 跳转锚点
this.root.navigateTo(href.substring(1)).catch(() => { })
} else if (href.split('?')[0].includes('://')) {
// 复制外部链接
if (this.root.copyLink) {
// #ifdef H5
window.open(href)
// #endif
// #ifdef MP
uni.setClipboardData({
data: href,
success: () => this.showTextToast(t('Mobile.Link.copied'))
})
// #endif
// #ifdef APP-PLUS
plus.runtime.openWeb(href)
// #endif
}
} else {
// 跳转页面
uni.navigateTo({
url: href,
fail () {
uni.switchTab({
url: href,
fail () { }
})
}
})
}
}
},
/**
* @description 复制代码事件
* @param {Event} e
*/
copyCode (e) {
// srcElement \u5728\u5fae\u4fe1\u5c0f\u7a0b\u5e8f\u4e2d\u4e0d\u5b58\u5728\uff0c\u52a0\u5b89\u5168\u8bbf\u95ee
const data = (e.srcElement && e.srcElement.dataset) || (e.currentTarget && e.currentTarget.dataset)
if(!data || data.action !== 'copy') {
return
}
const content = data.content
if (content) {
// 实现复制到剪贴板功能
uni.setClipboardData({
data: content,
success: () => {
this.showTextToast(t('Mobile.Common.copySuccess'))
},
fail: () => {
this.showTextToast(t('Mobile.Common.copyFailed'))
}
})
}
},
/**
* @description 当前节点点击事件
* @param {Event} e
*/
handleCurrentTap(e){
// #ifdef MP-WEIXIN
if (e.name==='div' && e.attrs?.class==='event') {
uni.$emit('message-event-delegate', e)
return
}
// #endif
// Handle copy code button in highlight blocks
if (e.attrs?.['data-action'] === 'copy' && e.attrs?.['data-content']) {
uni.setClipboardData({
data: e.attrs['data-content'],
success: () => {
this.showTextToast(t('Mobile.Common.copySuccess'))
},
fail: () => {
this.showTextToast(t('Mobile.Common.copyFailed'))
}
})
}
},
/**
* @description 复制代码文本(非 H5 平台使用)
* @param {String} text
*/
doCopyText (text) {
if (!text) return
uni.setClipboardData({
data: text,
success: () => {
this.showTextToast(t('Mobile.Common.copySuccess'))
},
fail: () => {
this.showTextToast(t('Mobile.Common.copyFailed'))
}
})
},
/**
* @description 错误事件
* @param {Event} e
*/
mediaError (e) {
const i = e.currentTarget.dataset.i
const node = this.childs[i]
// 加载其他源
if (node.name === 'video' || node.name === 'audio') {
let index = (this.ctrl[i] || 0) + 1
if (index > node.src.length) {
index = 0
}
if (index < node.src.length) {
this.$set(this.ctrl, i, index)
return
}
} else if (node.name === 'img') {
// #ifdef H5 && VUE3
if (this.opts[0] && !this.ctrl.load) return
// #endif
// 显示错误占位图
if (this.opts[2]) {
this.$set(this.ctrl, i, -1)
}
this.checkReady()
}
if (this.root) {
this.root.$emit('error', {
source: node.name,
attrs: node.attrs,
// #ifndef H5 && VUE3
errMsg: e.detail.errMsg
// #endif
})
}
}
}
}
</script>
<style lang="scss">
@import "../styles/index.scss";
// 会话输出内容点击事件
// 事件绑定配置样式
.div-event-style {
display: inline-block !important;
font-size: 12px;
margin: 0 4px;
padding: 4px 8px;
cursor: pointer;
max-width: 100px;
min-width: 18px;
border-radius: 24px;
color: rgba(0, 0, 0, 60%) !important;
background-color: rgba(0, 0, 0, 5%);
line-height: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background-color: rgba(235, 235, 235);
}
}
/* H5 平台hl-language 头部行的原生包裹层匹配 .hl-language 的布局 */
.hl-language-native {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 2px 10px;
background-color: #252526;
border-radius: 6px 6px 0 0;
font-size: 12px;
width: 100%;
box-sizing: border-box;
flex-shrink: 0;
}
/* H5 平台的原生可交互复制按钮 */
.hl-copy-btn-native {
display: flex;
align-items: center;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
.hl-copy-btn-text {
font-size: 10px;
color: #999;
line-height: normal;
white-space: normal;
}
}
/* 语言名称文本 */
.hl-language-name {
font-size: 12px;
color: #999;
flex: 1;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
/* HTML 基础元素样式 */
/* a 标签默认效果 */
._a {
padding: 1.5px 0 1.5px 0;
color: rgb(59, 130, 246);
word-break: break-all;
}
/* a 标签点击态效果 */
._hover {
text-decoration: underline;
opacity: 0.7;
}
/* 图片默认效果 */
._img {
max-width: 100%;
-webkit-touch-callout: none;
}
/* 内部样式 */
._block {
display: block;
}
._b,
._strong {
font-weight: bold;
}
._code {
font-family: monospace;
}
._del {
text-decoration: line-through;
}
._em,
._i {
font-style: italic;
}
._h1 {
font-size: 2em;
}
._h2 {
font-size: 1.5em;
}
._h3 {
font-size: 1.17em;
}
._h5 {
font-size: 0.83em;
}
._h6 {
font-size: 0.67em;
}
._h1,
._h2,
._h3,
._h4,
._h5,
._h6 {
display: block;
font-weight: bold;
}
._image {
height: 1px;
}
._ins {
text-decoration: underline;
}
._li {
display: list-item;
overflow: unset;
line-height: 1.5;
}
._ol {
list-style-type: decimal;
}
._ol,
._ul {
display: block;
padding-left: 40px;
margin: 1em 0;
}
._q::before {
content: '"';
}
._q::after {
content: '"';
}
._sub {
font-size: smaller;
vertical-align: sub;
}
._sup {
font-size: smaller;
vertical-align: super;
}
._thead,
._tbody,
._tfoot {
display: table-row-group;
}
._tr {
display: table-row;
}
._td,
._th {
display: table-cell;
vertical-align: middle;
}
._th {
font-weight: bold;
text-align: center;
}
._ul {
list-style-type: disc;
}
._ul ._ul {
margin: 0;
list-style-type: circle;
}
._ul ._ul ._ul {
list-style-type: square;
}
._abbr,
._b,
._code,
._del,
._em,
._i,
._ins,
._label,
._q,
._span,
._strong,
._sub,
._sup {
display: inline;
}
/* #ifdef APP-PLUS */
._video {
width: 300px;
height: 225px;
}
/* #endif */

View File

@@ -0,0 +1,238 @@
/* 代码高亮样式 - 基于 uni-ai-msg-code 深色主题 */
/* FiraCode 字体已被移除以减小包体积 */
/* @font-face {
font-family: CodeFontFamily;
src: url('@uni_modules/mp-html/static/font/FiraCode-Regular.ttf') format('truetype');
} */
:deep(.hl-code) {
color: #d4d4d4;
background: transparent;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 14px;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: hidden; /* 防止代码区自己产生内部垂直滚动条 */
padding: 10px 12px; /* 代码内容的内边距 */
box-sizing: border-box;
min-height: 100%;
align-self: stretch;
}
:deep(.hl-pre) {
color: #d4d4d4;
background: #1e1e1e;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 14px;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
border-radius: 6px;
position: relative;
display: flex;
flex-direction: column;
padding: 0; /* Remove old padding */
overflow: hidden; /* Stop whole wrapper from sliding */
}
/* 代码头部样式 */
:deep(.hl-language) {
margin: 0; /* 确保没有外边距 */
line-height: normal; /* 重置行高 */
white-space: normal; /* 重置空白处理 */
user-select: none;
position: relative;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 2px 10px;
background-color: #252526;
border-radius: 6px 6px 0 0;
font-size: 12px;
color: #999;
}
:deep(.hl-body) {
/* wrapper handle scrolling ONLY */
max-height: 60vh;
overflow-y: auto; /* vertically scroll the code and line numbers */
overflow-x: hidden;
width: 100%;
position: relative;
background-color: #1e1e1e;
}
:deep(.hl-container) {
display: flex;
flex-direction: row;
align-items: stretch; /* flex cross-axis stretch without scroll boundaries */
width: 100%;
box-sizing: border-box;
}
/* 代码高亮颜色 - 基于 uni-ai-msg-code 深色主题 */
:deep(.hl-block-comment),
:deep(.hl-cdata),
:deep(.hl-comment),
:deep(.hl-doctype),
:deep(.hl-prolog) {
color: #999; /* 注释使用灰色 */
}
:deep(.hl-punctuation) {
color: #d4d4d4; /* 标点符号使用默认文本颜色 */
}
:deep(.hl-attr-name),
:deep(.hl-deleted),
:deep(.hl-namespace),
:deep(.hl-tag) {
color: #d16969; /* 元信息使用红色 */
}
:deep(.hl-function-name) {
color: #dcdcaa; /* 函数使用黄色 */
}
:deep(.hl-boolean),
:deep(.hl-function),
:deep(.hl-number) {
color: #b5cea8; /* 常量使用浅绿色 */
}
:deep(.hl-class-name),
:deep(.hl-constant),
:deep(.hl-property),
:deep(.hl-symbol) {
color: #4ec9b0; /* 实体使用青色 */
}
:deep(.hl-atrule),
:deep(.hl-builtin),
:deep(.hl-important),
:deep(.hl-keyword),
:deep(.hl-selector) {
color: #569cd6; /* 关键字使用蓝色 */
}
:deep(.hl-attr-value),
:deep(.hl-char),
:deep(.hl-regex),
:deep(.hl-string),
:deep(.hl-variable) {
color: #ce9178; /* 字符串使用橙色 */
}
:deep(.hl-entity),
:deep(.hl-operator),
:deep(.hl-url) {
color: #4ec9b0; /* 支持类使用青色 */
}
:deep(.hl-bold),
:deep(.hl-important) {
font-weight: 700;
}
:deep(.hl-italic) {
font-style: italic;
}
:deep(.hl-entity) {
cursor: help;
}
:deep(.hl-inserted) {
color: #4ec9b0; /* 插入内容使用青色 */
}
/* 行号样式 */
:deep(.line-numbers-rows) {
position: static;
flex-shrink: 0;
min-width: 3.5em;
text-align: center;
color: #666;
font-size: 14px;
line-height: 1.5;
user-select: none;
padding-top: 10px; /* align with code padding */
padding-bottom: 10px;
background-color: transparent;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
box-sizing: border-box;
min-height: 100%;
align-self: stretch;
}
:deep(.line-numbers-rows .span) {
display: flex;
height: 1.5em; /* Match code line height */
align-items: center;
justify-content: center;
}
/* 复制按钮样式 */
:deep(.hl-copy-btn) {
display: flex;
align-items: center;
padding: 2px 4px;
border-radius: 4px;
cursor: pointer;
font-size: 10px;
color: #999;
}
:deep(.hl-copy-btn:hover) {
background-color: #37373d;
}
/* 滚动条样式 */
:deep(.hl-pre::-webkit-scrollbar) {
height: 8px;
}
:deep(.hl-pre::-webkit-scrollbar-track) {
background: #252526;
border-radius: 4px;
}
:deep(.hl-pre::-webkit-scrollbar-thumb) {
background: #666;
border-radius: 4px;
}
:deep(.hl-pre::-webkit-scrollbar-thumb:hover) {
background: #888;
}

Some files were not shown because too many files have changed in this diff Show More