chore: initialize qiming workspace repository
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
206
qiming-mobile/subpackages/components/file-preview-h5.vue
Normal file
206
qiming-mobile/subpackages/components/file-preview-h5.vue
Normal 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>
|
||||
@@ -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>
|
||||
14
qiming-mobile/subpackages/constants/event.constants.uts
Normal file
14
qiming-mobile/subpackages/constants/event.constants.uts
Normal 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, // 会话结束
|
||||
};
|
||||
203
qiming-mobile/subpackages/hooks/useMessageEventDelegate.uts
Normal file
203
qiming-mobile/subpackages/hooks/useMessageEventDelegate.uts
Normal 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
|
||||
|
||||
}
|
||||
161
qiming-mobile/subpackages/hooks/usePageResume.uts
Normal file
161
qiming-mobile/subpackages/hooks/usePageResume.uts
Normal 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;
|
||||
215
qiming-mobile/subpackages/pages/about-me/about-me.uvue
Normal file
215
qiming-mobile/subpackages/pages/about-me/about-me.uvue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
741
qiming-mobile/subpackages/pages/agent-search/agent-search.uvue
Normal file
741
qiming-mobile/subpackages/pages/agent-search/agent-search.uvue
Normal 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>
|
||||
@@ -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>
|
||||
83
qiming-mobile/subpackages/pages/app-details/app-details.uvue
Normal file
83
qiming-mobile/subpackages/pages/app-details/app-details.uvue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<view class="agent-conversation-history h-full">
|
||||
<scroll-view
|
||||
class="h-full"
|
||||
scroll-y
|
||||
@scrolltolower="handleLoadMore"
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="handleRefresh"
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<!-- 会话列表 -->
|
||||
<template v-if="conversationList?.length">
|
||||
<view class="list-wrapper">
|
||||
<view
|
||||
class="conversation-item"
|
||||
hover-class="hover-class"
|
||||
hover-start-time="50"
|
||||
v-for="item in conversationList"
|
||||
:key="item.id"
|
||||
@click="onConversationClick(item)"
|
||||
>
|
||||
<text class="conversation-title text-ellipsis">{{
|
||||
item.topic || t("Mobile.Conversation.unnamed")
|
||||
}}</text>
|
||||
<text class="conversation-date">{{
|
||||
formatDate(item.modified)
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- loading状态或刷新状态时隐藏空状态 -->
|
||||
<template v-else-if="!loading && !refreshing">
|
||||
<view class="empty-state h-full">
|
||||
<empty-state />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 刷新状态时隐藏加载状态 -->
|
||||
<template v-else-if="!refreshing">
|
||||
<view class="loading-state">
|
||||
<text class="loading-text">{{ t("Mobile.Page.loadingMore") }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 没有更多数据提示 -->
|
||||
<template v-if="showNoMore">
|
||||
<view class="no-more-state">
|
||||
<text class="no-more-text">{{ t("Mobile.Common.noMoreData") }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import type {
|
||||
ConversationInfo,
|
||||
ConversationListParams,
|
||||
} from "@/types/interfaces/conversationInfo";
|
||||
import { formatDate } from "@/utils/system";
|
||||
import { jumpToAgentDetailPage } from "@/utils/commonBusiness";
|
||||
import { apiAgentConversationList } from "@/servers/conversation";
|
||||
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
|
||||
import { useI18n } from "@/utils/i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface Props {
|
||||
agentId: number;
|
||||
}
|
||||
|
||||
// 接收组件属性
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
// 会话列表
|
||||
const conversationList = ref<ConversationInfo[]>([]);
|
||||
// 加载状态
|
||||
const loading = ref<boolean>(false);
|
||||
// 刷新状态
|
||||
const refreshing = ref<boolean>(false);
|
||||
// 是否还有更多
|
||||
const hasMore = ref<boolean>(true);
|
||||
// 最后一条记录ID
|
||||
const lastId = ref<number | null>(null);
|
||||
|
||||
// 是否显示没有更多数据
|
||||
const showNoMore = computed(() => {
|
||||
return (
|
||||
!loading.value && !hasMore.value && conversationList.value?.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
// 获取会话列表
|
||||
const fetchConversationList = async (isLoadMore: boolean = false) => {
|
||||
try {
|
||||
const params: ConversationListParams = {
|
||||
agentId: props.agentId,
|
||||
lastId: isLoadMore ? lastId.value : null,
|
||||
limit: 20,
|
||||
} as ConversationListParams;
|
||||
|
||||
const res = await apiAgentConversationList(params);
|
||||
loading.value = false;
|
||||
|
||||
const { code, data } = res || {};
|
||||
if (code === SUCCESS_CODE) {
|
||||
const newList = (data || []) as ConversationInfo[];
|
||||
|
||||
if (isLoadMore) {
|
||||
// 追加数据
|
||||
conversationList.value = [...conversationList.value, ...newList];
|
||||
} else {
|
||||
// 替换数据
|
||||
conversationList.value = newList;
|
||||
}
|
||||
|
||||
// 更新lastId和hasMore
|
||||
if (data && data.length > 0) {
|
||||
lastId.value = data[data.length - 1].id;
|
||||
hasMore.value = newList.length >= 20;
|
||||
} else {
|
||||
hasMore.value = false;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
loading.value = false;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 下拉刷新
|
||||
const handleRefresh = async () => {
|
||||
if (refreshing.value) {
|
||||
return;
|
||||
}
|
||||
refreshing.value = true;
|
||||
lastId.value = null;
|
||||
hasMore.value = true;
|
||||
|
||||
await fetchConversationList(false);
|
||||
|
||||
setTimeout(() => {
|
||||
refreshing.value = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = () => {
|
||||
if (hasMore.value && !loading.value) {
|
||||
loading.value = true;
|
||||
fetchConversationList(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 会话项点击处理
|
||||
const onConversationClick = (item: ConversationInfo) => {
|
||||
// 跳转到智能体详情页面,并传递消息数据
|
||||
jumpToAgentDetailPage(item.agentId, item.id);
|
||||
};
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loading.value = true;
|
||||
fetchConversationList(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.agent-conversation-history {
|
||||
.list-wrapper {
|
||||
padding: 16rpx;
|
||||
|
||||
.conversation-item {
|
||||
height: 76rpx;
|
||||
padding: 0 16rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
gap: 32rpx;
|
||||
border-radius: 16rpx;
|
||||
|
||||
&.hover-class {
|
||||
background-color: rgba(12, 20, 102, 0.04);
|
||||
}
|
||||
|
||||
.conversation-title {
|
||||
color: #000;
|
||||
font-size: 28rpx;
|
||||
font-weight: 400;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.conversation-date {
|
||||
text-align: right;
|
||||
font-size: 24rpx;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-state {
|
||||
width: 100%;
|
||||
padding: 40rpx 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.loading-text {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border: 4rpx solid #e8e8e8;
|
||||
border-top: 4rpx solid #8b5cf6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 没有更多数据状态 */
|
||||
.no-more-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40rpx;
|
||||
|
||||
.no-more-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,560 @@
|
||||
<template>
|
||||
<modal-popup
|
||||
ref="modalRef"
|
||||
:title="t('Mobile.Chat.subscriptionModalTitle')"
|
||||
width="90vw"
|
||||
@update-visible="handleUpdateVisible"
|
||||
>
|
||||
<view class="agent-sub-container">
|
||||
<!-- 骨架屏 / 加载状态 -->
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text class="loading-icon">⏳</text>
|
||||
<text class="loading-text">{{ t("Mobile.Common.loading") }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else-if="plans.length === 0" class="empty-state">
|
||||
<text class="empty-icon">📁</text>
|
||||
<text class="empty-text">{{ t("Mobile.MySubscriptions.empty") }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 套餐卡片列表 -->
|
||||
<scroll-view
|
||||
v-else
|
||||
class="plans-scroll"
|
||||
scroll-y="true"
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<view class="plans-list">
|
||||
<view
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
class="plan-card"
|
||||
:class="{
|
||||
'plan-current': plan.id === activePlanId,
|
||||
'plan-expired':
|
||||
plan.id === activePlanId &&
|
||||
currentSubscription?.status === 'EXPIRED',
|
||||
'plan-cancelled':
|
||||
plan.id === activePlanId &&
|
||||
currentSubscription?.status === 'CANCELLED',
|
||||
}"
|
||||
>
|
||||
<!-- 热门标签 / 订阅状态标签 -->
|
||||
<view
|
||||
class="card-badge-row"
|
||||
v-if="plan.id === activePlanId || plan.isHot"
|
||||
>
|
||||
<view
|
||||
v-if="plan.id === activePlanId"
|
||||
class="badge"
|
||||
:class="{
|
||||
'badge-active':
|
||||
currentSubscription == null ||
|
||||
currentSubscription.status === 'ACTIVE',
|
||||
'badge-expired':
|
||||
currentSubscription != null &&
|
||||
currentSubscription.status === 'EXPIRED',
|
||||
'badge-cancelled':
|
||||
currentSubscription != null &&
|
||||
currentSubscription.status === 'CANCELLED',
|
||||
}"
|
||||
>
|
||||
<text class="badge-text">{{
|
||||
currentSubscription != null &&
|
||||
plan.id === currentSubscription.planId
|
||||
? getSubscriptionStatusText(currentSubscription.status)
|
||||
: t("Mobile.MySubscriptions.currentPlan")
|
||||
}}</text>
|
||||
</view>
|
||||
<view v-else-if="plan.isHot" class="badge badge-hot">
|
||||
<text class="badge-text">{{
|
||||
t("Mobile.MySubscriptions.statusHot")
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 计划核心信息 -->
|
||||
<view class="card-main">
|
||||
<view class="plan-info-header">
|
||||
<text class="plan-name">{{ plan.name }}</text>
|
||||
<text class="plan-desc" v-if="plan.description">{{
|
||||
plan.description
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 价格与计费周期 -->
|
||||
<view class="plan-price-box">
|
||||
<text class="price-symbol">¥</text>
|
||||
<text class="price-value">{{
|
||||
formatNumber(plan.price, 2)
|
||||
}}</text>
|
||||
<text class="price-period">{{
|
||||
getPeriodUnit(plan.period)
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 权益清单 -->
|
||||
<view class="plan-limits">
|
||||
<view class="limit-item" v-if="plan.callLimitCount != null">
|
||||
<text class="limit-bullet">✦</text>
|
||||
<text class="limit-label"
|
||||
>{{ t("Mobile.Chat.callLimitLabel") }}:</text
|
||||
>
|
||||
<text class="limit-val">{{
|
||||
plan.callLimitCount === -1
|
||||
? t("Mobile.Chat.unlimited")
|
||||
: plan.callLimitCount + t("Mobile.Chat.timesPerMonth")
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="card-action">
|
||||
<view
|
||||
class="action-btn"
|
||||
:class="{
|
||||
'btn-current':
|
||||
plan.id === activePlanId &&
|
||||
currentSubscription?.status !== 'CANCELLED',
|
||||
'btn-processing': processingId === plan.id,
|
||||
'btn-disabled': plan.price <= 0,
|
||||
}"
|
||||
@tap="handleSelectPlan(plan)"
|
||||
>
|
||||
<text class="btn-text">{{ getActionText(plan) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</modal-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useI18n } from "@/utils/i18n";
|
||||
import {
|
||||
apiGetAgentSubscriptionPlanList,
|
||||
apiGetMySubscription,
|
||||
} from "@/subpackages/servers/subscription";
|
||||
import {
|
||||
SystemSubscriptionPlan,
|
||||
MySubscriptionItem,
|
||||
} from "@/subpackages/types/interfaces/subscription";
|
||||
import { formatNumber } from "@/utils/system";
|
||||
import { useSubscriptionPurchase } from "@/subpackages/pages/my-subscriptions/hooks/useSubscriptionPurchase.uts";
|
||||
import { getPeriodUnit } from "@/subpackages/pages/my-subscriptions/utils.uts";
|
||||
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
agentId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
currentPlanId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
currentPlanPrice: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["purchase-success", "update-visible"]);
|
||||
|
||||
const handleUpdateVisible = (visible: boolean) => {
|
||||
emit("update-visible", visible);
|
||||
};
|
||||
|
||||
const modalRef = ref<any>(null);
|
||||
const loading = ref(false);
|
||||
const plans = ref<SystemSubscriptionPlan[]>([]);
|
||||
const { handlePaySubscription, processingId } = useSubscriptionPurchase();
|
||||
const currentSubscription = ref<MySubscriptionItem | null>(null);
|
||||
|
||||
/**
|
||||
* 动态计算生效中的 planId
|
||||
*/
|
||||
const activePlanId = computed((): number | null => {
|
||||
if (currentSubscription.value != null) {
|
||||
return currentSubscription.value!.planId;
|
||||
}
|
||||
return props.currentPlanId;
|
||||
});
|
||||
|
||||
/**
|
||||
* 动态计算生效中的 plan 价格,便于判断升级关系
|
||||
*/
|
||||
const activePlanPrice = computed((): number | null => {
|
||||
if (
|
||||
currentSubscription.value != null &&
|
||||
currentSubscription.value!.plan != null
|
||||
) {
|
||||
return currentSubscription.value!.plan.price;
|
||||
}
|
||||
return props.currentPlanPrice;
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取订阅状态文本
|
||||
*/
|
||||
const getSubscriptionStatusText = (status: string): string => {
|
||||
if (status === "ACTIVE") {
|
||||
return t("Mobile.MySubscriptions.stateActive");
|
||||
}
|
||||
if (status === "EXPIRED") {
|
||||
return t("Mobile.MySubscriptions.stateExpired");
|
||||
}
|
||||
if (status === "CANCELLED") {
|
||||
return t("Mobile.MySubscriptions.stateCancelled");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算操作按钮的文本(对齐 PC 端逻辑)
|
||||
* - 当前使用中套餐(ACTIVE/EXPIRED): "当前套餐(续订)",支持点击续订
|
||||
* - 当前套餐已取消(CANCELLED): "订阅"
|
||||
* - 无订阅 / 价格 <= 当前使用金额: "订阅"
|
||||
* - 价格 > 当前使用金额: "升级"
|
||||
*/
|
||||
const getActionText = (plan: SystemSubscriptionPlan): string => {
|
||||
const curPlanId = activePlanId.value;
|
||||
if (plan.id === curPlanId) {
|
||||
if (currentSubscription.value != null) {
|
||||
const status = currentSubscription.value!.status;
|
||||
if (status === "CANCELLED") {
|
||||
return t("Mobile.MySubscriptions.subscribeNow");
|
||||
}
|
||||
}
|
||||
// 使用中/已过期的当前套餐:显示"当前套餐(续订)"
|
||||
const currentPlanText = t("Mobile.MySubscriptions.currentPlanButton");
|
||||
const renewText = t("Mobile.MySubscriptions.renewNow");
|
||||
return `${currentPlanText}(${renewText})`;
|
||||
}
|
||||
if (curPlanId == null) {
|
||||
return t("Mobile.MySubscriptions.subscribeNow");
|
||||
}
|
||||
// 使用当前生效套餐的金额作为判断基准
|
||||
const currentPrice = activePlanPrice.value ?? 0;
|
||||
if (plan.price <= currentPrice) {
|
||||
return t("Mobile.MySubscriptions.subscribeNow");
|
||||
}
|
||||
return t("Mobile.MySubscriptions.upgradeNow");
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载智能体订阅及套餐列表
|
||||
*/
|
||||
const fetchPlans = async () => {
|
||||
if (props.agentId <= 0) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
// 1. 获取当前智能体订阅情况
|
||||
const subRes = await apiGetMySubscription({
|
||||
bizId: props.agentId.toString(),
|
||||
bizType: "AGENT",
|
||||
});
|
||||
if (subRes.code === SUCCESS_CODE && subRes.data != null) {
|
||||
currentSubscription.value = subRes.data!.currentSubscription;
|
||||
} else {
|
||||
currentSubscription.value = null;
|
||||
}
|
||||
|
||||
// 2. 获取套餐列表
|
||||
const res = await apiGetAgentSubscriptionPlanList({
|
||||
agentId: props.agentId,
|
||||
status: 1,
|
||||
});
|
||||
if (res.code === SUCCESS_CODE && res.data != null) {
|
||||
plans.value = res.data!;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[AgentSubscriptionModal] 加载数据失败:", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectPlan = async (plan: SystemSubscriptionPlan) => {
|
||||
if (plan.price <= 0) return;
|
||||
await handlePaySubscription(plan.id);
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开弹窗
|
||||
*/
|
||||
const open = () => {
|
||||
modalRef.value?.open();
|
||||
fetchPlans();
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
*/
|
||||
const close = () => {
|
||||
modalRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.agent-sub-container {
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60rpx 0;
|
||||
|
||||
.loading-icon,
|
||||
.empty-icon {
|
||||
font-size: 56rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.empty-text {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.plans-scroll {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.plans-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
padding: 4rpx;
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
position: relative;
|
||||
background-color: #ffffff;
|
||||
border: 2rpx solid #eaeaea;
|
||||
border-radius: 24rpx;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.03);
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&.plan-current {
|
||||
border-color: #007aff;
|
||||
background-color: #f7faff;
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 122, 255, 0.08);
|
||||
}
|
||||
|
||||
&.plan-expired {
|
||||
border-color: #86909c;
|
||||
background-color: #f2f3f5;
|
||||
box-shadow: 0 8rpx 32rpx rgba(134, 144, 156, 0.08);
|
||||
}
|
||||
|
||||
&.plan-cancelled {
|
||||
border-color: #f53f3f;
|
||||
background-color: #fff7f7;
|
||||
box-shadow: 0 8rpx 32rpx rgba(245, 63, 63, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.card-badge-row {
|
||||
position: absolute;
|
||||
top: -2rpx;
|
||||
right: -2rpx;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.badge {
|
||||
height: 48rpx;
|
||||
padding: 0 24rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0 24rpx 0 32rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
|
||||
&-active {
|
||||
background: linear-gradient(135deg, #007aff, #0056b3);
|
||||
}
|
||||
|
||||
&-expired {
|
||||
background: linear-gradient(135deg, #86909c, #4e5969);
|
||||
}
|
||||
|
||||
&-cancelled {
|
||||
background: linear-gradient(135deg, #f53f3f, #b71c1c);
|
||||
}
|
||||
|
||||
&-hot {
|
||||
background: linear-gradient(135deg, #ff9500, #ff5e00);
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
font-size: 20rpx;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-main {
|
||||
flex: 1;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.plan-info-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
padding-right: 120rpx;
|
||||
|
||||
.plan-name {
|
||||
font-size: 32rpx;
|
||||
color: #333333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.plan-desc {
|
||||
font-size: 22rpx;
|
||||
color: #777777;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-price-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
.price-symbol {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
margin-right: 4rpx;
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.price-period {
|
||||
font-size: 26rpx;
|
||||
color: #888888;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-limits {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 16rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
|
||||
.limit-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: 24rpx;
|
||||
|
||||
.limit-bullet {
|
||||
color: #007aff;
|
||||
margin-right: 12rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.limit-label {
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.limit-val {
|
||||
color: #333333;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-action {
|
||||
width: 100%;
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 76rpx;
|
||||
background: #1e6deb;
|
||||
border-radius: 12rpx;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&.btn-current {
|
||||
background: #efebe6;
|
||||
|
||||
.btn-text {
|
||||
color: #5c564f;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-processing {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.btn-disabled {
|
||||
background: #f5f5f5;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
|
||||
.btn-text {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 26rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
line-height: 26rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<view
|
||||
v-if="node.type === 'folder'"
|
||||
class="folder-item"
|
||||
:style="`margin-left: ${level * 16}rpx`"
|
||||
>
|
||||
<view
|
||||
class="folder-header"
|
||||
@tap="handleToggleFolder"
|
||||
>
|
||||
<!-- 文件夹展开/收起图标 -->
|
||||
<text class="folder-caret iconfont icon-a-Chevronright"
|
||||
:class="{ expanded: isExpanded }"></text>
|
||||
<view class="folder-name">{{ node.name }}</view>
|
||||
</view>
|
||||
<view v-if="isExpanded && node.children && !!node.children.length" class="file-list">
|
||||
<file-tree-node
|
||||
v-for="child in node.children"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:level="level + 1"
|
||||
:expanded-folders="expandedFolders"
|
||||
:selected-file-id="selectedFileId"
|
||||
@toggle-folder="handleChildToggleFolder"
|
||||
@select-file="handleChildSelectFile"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
v-else
|
||||
class="file-item"
|
||||
:class="{ 'active-file': isSelected, 'hidden-file': node.name.startsWith('.') }"
|
||||
:style="`margin-left: ${level * 8}rpx`"
|
||||
@tap.stop="handleSelectFile"
|
||||
>
|
||||
<view class="file-icon-wrapper">
|
||||
<image
|
||||
class="file-icon"
|
||||
:src="fileIcon"
|
||||
mode="aspectFit"
|
||||
alt=""
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</view>
|
||||
<view class="file-name">{{ node.name }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { computed } from 'vue'
|
||||
import type { FileNode } from '@/types/interfaces/agent'
|
||||
import FileTreeNode from './file-tree-node.uvue'
|
||||
import { getFileIcon } from './fileIconHelper.uts'
|
||||
import iconDefault from '@/static/filetree/icon_default.svg'
|
||||
|
||||
interface Props {
|
||||
node: FileNode
|
||||
level: number
|
||||
expandedFolders: Set<string>
|
||||
selectedFileId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleFolder: [folderId: string]
|
||||
selectFile: [fileNode: FileNode]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 是否展开
|
||||
*/
|
||||
const isExpanded = computed(() => {
|
||||
return props.expandedFolders.has(props.node.id)
|
||||
})
|
||||
|
||||
/**
|
||||
* 是否选中
|
||||
*/
|
||||
const isSelected = computed(() => {
|
||||
return props.selectedFileId === props.node.id
|
||||
})
|
||||
|
||||
/**
|
||||
* 文件图标路径
|
||||
*/
|
||||
const fileIcon = computed<string>(() => {
|
||||
if (props.node?.type === 'file' || props.node?.isLink) {
|
||||
return getFileIcon(props.node.name)
|
||||
}
|
||||
return iconDefault
|
||||
})
|
||||
|
||||
/**
|
||||
* 图片加载错误处理
|
||||
*/
|
||||
const handleImageError = (e: any) => {
|
||||
console.error('Image load error:', fileIcon.value, e)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换文件夹展开/收起
|
||||
*/
|
||||
const handleToggleFolder = () => {
|
||||
if (props.node.type === 'folder') {
|
||||
emit('toggleFolder', props.node.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择文件
|
||||
*/
|
||||
const handleSelectFile = () => {
|
||||
if (props.node.type === 'file' && !props.node.name.startsWith('.')) {
|
||||
emit('selectFile', props.node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理子组件的切换文件夹事件
|
||||
*/
|
||||
const handleChildToggleFolder = (folderId: string) => {
|
||||
emit('toggleFolder', folderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理子组件的选择文件事件
|
||||
*/
|
||||
const handleChildSelectFile = (fileNode: FileNode) => {
|
||||
emit('selectFile', fileNode)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/static/iconfont/iconfont.css';
|
||||
|
||||
.folder-item {
|
||||
padding: 0rpx 0 0 32rpx;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
|
||||
.folder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
padding: 16rpx 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
min-height: 40rpx;
|
||||
white-space: nowrap;
|
||||
|
||||
.folder-caret {
|
||||
font-size: 18rpx;
|
||||
color: #5e5e5b;
|
||||
margin-right: 12rpx;
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
// display: inline-block;
|
||||
// width: 18rpx;
|
||||
// text-align: center;
|
||||
// line-height: 1;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
// .folder-icon {
|
||||
// font-size: 28rpx;
|
||||
// color: #8b9dc3;
|
||||
// margin-right: 6rpx;
|
||||
// display: inline-block;
|
||||
// line-height: 1;
|
||||
// }
|
||||
|
||||
.folder-name {
|
||||
font-size: 26rpx;
|
||||
color: #5e5e5b;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
padding: 16rpx 32rpx;
|
||||
color: #5e5e5b;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
min-height: 40rpx;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active-file {
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
&.hidden-file {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.file-icon-wrapper {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
.file-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
display: block;
|
||||
color: #5e5e5b;
|
||||
}
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 26rpx;
|
||||
color: #5e5e5b;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<drawer-popup
|
||||
ref="popupFileTreeRef"
|
||||
direction="bottom"
|
||||
height="85vh"
|
||||
:title="t('Mobile.Common.workspace')"
|
||||
@update-visible="updateVisible"
|
||||
>
|
||||
<view class="file-tree">
|
||||
<view class="file-tree-wrapper" ref="fileTreeWrapperRef">
|
||||
<!-- <text class="test-long-text">噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个噶事根深蒂固萨达噶是的gsasag工地萨嘎搜嘎十多个</text> -->
|
||||
|
||||
<!-- 加载中, 刷新时,不显示加载图标 -->
|
||||
<template v-if="isLoading && !files?.length">
|
||||
<view class="file-tree-loading">
|
||||
<image
|
||||
class="icon-loading-image"
|
||||
src="@/static/assets/icon_loading.svg"
|
||||
mode="widthFix"
|
||||
alt=""
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<!-- 有文件时,显示文件树 -->
|
||||
<template v-else-if="!!files?.length">
|
||||
<file-tree-node
|
||||
v-for="node in files"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:level="0"
|
||||
:expanded-folders="expandedFolders"
|
||||
:selected-file-id="selectedFileId"
|
||||
@toggle-folder="handleToggleFolder"
|
||||
@select-file="handleSelectFile"
|
||||
/>
|
||||
</template>
|
||||
<!-- 没有文件时,显示空状态 -->
|
||||
<template v-else>
|
||||
<view class="file-tree-empty">
|
||||
<text>{{ t("Mobile.FileTree.noFiles") }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
<view
|
||||
class="download-button"
|
||||
v-if="!isLoading && files?.length > 0"
|
||||
:style="{
|
||||
bottom: buttonPosition.bottom + 'rpx',
|
||||
right: buttonPosition.right + 'rpx',
|
||||
}"
|
||||
@click="handleDownload"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
>
|
||||
<text class="download-icon iconfont icon-a-Arrowdown"></text>
|
||||
</view>
|
||||
</view>
|
||||
</drawer-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { onMounted } from "vue";
|
||||
import DrawerPopup from "@/components/drawer-popup/drawer-popup.uvue";
|
||||
import type { FileNode } from "@/types/interfaces/agent";
|
||||
import FileTreeNode from "./file-tree-node.uvue";
|
||||
import { apiDownloadAllFiles } from "@/servers/agentDev.uts";
|
||||
import { API_BASE_URL } from "@/constants/config";
|
||||
import { SUCCESS_CODE } from "@/constants/codes.constants";
|
||||
import { useI18n } from "@/utils/i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// #ifdef H5 || WEB
|
||||
import { exportWholeProjectZipH5 } from "@/subpackages/utils/fileTree";
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
|
||||
import { downloadFileInMiniProgram } from "@/subpackages/utils/fileTree";
|
||||
// #endif
|
||||
|
||||
// 文件树弹窗引用
|
||||
const popupFileTreeRef = ref<any>(null);
|
||||
// 文件树包装器引用
|
||||
const fileTreeWrapperRef = ref<any>(null);
|
||||
// 已展开的文件夹ID集合
|
||||
const expandedFolders = ref<Set<string>>(new Set());
|
||||
// 选中的文件ID
|
||||
const selectedFileId = ref<string>("");
|
||||
|
||||
// 下载按钮位置
|
||||
const buttonPosition = ref<{ bottom: number; right: number }>({
|
||||
bottom: 50,
|
||||
right: 50,
|
||||
});
|
||||
|
||||
// 拖拽相关状态
|
||||
const isDragging = ref<boolean>(false);
|
||||
const dragStartPos = ref<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const buttonStartPos = ref<{ bottom: number; right: number }>({
|
||||
bottom: 50,
|
||||
right: 50,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
cId: number;
|
||||
files: FileNode[];
|
||||
isLoading: boolean;
|
||||
}>(),
|
||||
{
|
||||
cId: 0,
|
||||
files: [],
|
||||
isLoading: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits(["update-visible"]);
|
||||
|
||||
// 处理文件树弹窗可见性变化
|
||||
const updateVisible = (value: boolean) => {
|
||||
emit("update-visible", value);
|
||||
};
|
||||
|
||||
// 导出项目
|
||||
const handleExportProject = async () => {
|
||||
// 检查项目ID是否有效
|
||||
if (!props.cId) {
|
||||
uni.showToast({
|
||||
title: t("Mobile.FileTree.invalidConversationId"),
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uni.showLoading({ title: t("Mobile.FileTree.preparingDownload") });
|
||||
|
||||
// #ifdef H5 || WEB
|
||||
// H5 环境:使用 blob 下载
|
||||
const result = await apiDownloadAllFiles(props.cId);
|
||||
const filename = `chat-${props.cId}.zip`;
|
||||
await exportWholeProjectZipH5(
|
||||
result.data as Blob,
|
||||
result.headers,
|
||||
filename,
|
||||
);
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
title: t("Mobile.FileTree.exportSuccess"),
|
||||
icon: "success",
|
||||
});
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
|
||||
// 小程序环境:直接使用下载地址(后端已打包成zip)
|
||||
let downloadUrl = `${API_BASE_URL}/api/computer/static/download-all-files?cId=${props.cId}`;
|
||||
const filename = `chat-${props.cId}.zip`;
|
||||
|
||||
// 下载并保存zip文件,返回保存路径(函数内部已弹窗提示用户)
|
||||
const savedFilePath = await downloadFileInMiniProgram(
|
||||
downloadUrl,
|
||||
filename,
|
||||
);
|
||||
console.log("文件保存路径:", savedFilePath);
|
||||
// #endif
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
// 改进错误处理,兼容不同的错误格式
|
||||
const errorMessage =
|
||||
(error as any)?.message || t("Mobile.FileTree.exportUnknownError");
|
||||
|
||||
uni.showToast({
|
||||
title: t("Mobile.FileTree.exportFailed", { message: errorMessage }),
|
||||
icon: "none",
|
||||
});
|
||||
console.error("导出失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
const handleDownload = () => {
|
||||
if (isDragging.value) {
|
||||
return; // 如果正在拖拽,不触发下载
|
||||
}
|
||||
handleExportProject();
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载按钮位置
|
||||
*/
|
||||
const loadButtonPosition = () => {
|
||||
try {
|
||||
const saved = uni.getStorageSync("file-tree-download-button-position");
|
||||
if (saved) {
|
||||
buttonPosition.value = saved;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("加载按钮位置失败:", e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存按钮位置
|
||||
*/
|
||||
const saveButtonPosition = () => {
|
||||
try {
|
||||
uni.setStorageSync(
|
||||
"file-tree-download-button-position",
|
||||
buttonPosition.value,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("保存按钮位置失败:", e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 触摸开始
|
||||
*/
|
||||
const handleTouchStart = (e: any) => {
|
||||
const touch = e.touches?.[0];
|
||||
if (!touch) return;
|
||||
|
||||
// 使用 clientX/clientY 获取触摸坐标
|
||||
dragStartPos.value = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
};
|
||||
buttonStartPos.value = { ...buttonPosition.value };
|
||||
isDragging.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 触摸移动
|
||||
*/
|
||||
const handleTouchMove = (e: any) => {
|
||||
const touch = e.touches?.[0];
|
||||
if (!touch) return;
|
||||
|
||||
const currentX = touch.clientX;
|
||||
const currentY = touch.clientY;
|
||||
const deltaX = currentX - dragStartPos.value.x;
|
||||
const deltaY = currentY - dragStartPos.value.y;
|
||||
|
||||
// 如果移动距离超过10px,认为是拖拽
|
||||
if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {
|
||||
isDragging.value = true;
|
||||
}
|
||||
|
||||
if (isDragging.value) {
|
||||
// 获取系统信息,计算屏幕尺寸
|
||||
const systemInfo = uni.getSystemInfoSync();
|
||||
const screenWidth = systemInfo.windowWidth;
|
||||
const screenHeight = systemInfo.windowHeight;
|
||||
|
||||
// 将px转换为rpx (1rpx = screenWidth / 750)
|
||||
const rpxRatio = screenWidth / 750;
|
||||
|
||||
// 计算新位置
|
||||
// deltaX: 向右移动为正,所以 right 应该减小
|
||||
// deltaY: 向下移动为正,所以 bottom 应该减小(因为 bottom 是从底部向上的距离)
|
||||
let newRight = buttonStartPos.value.right - deltaX / rpxRatio;
|
||||
let newBottom = buttonStartPos.value.bottom - deltaY / rpxRatio;
|
||||
|
||||
// 限制在屏幕范围内
|
||||
const buttonWidth = 120; // 按钮宽度 120rpx
|
||||
const buttonHeight = 120; // 按钮高度 120rpx
|
||||
const minRight = 0;
|
||||
const maxRight = screenWidth / rpxRatio - buttonWidth;
|
||||
const minBottom = 0;
|
||||
const maxBottom = screenHeight / rpxRatio - buttonHeight;
|
||||
|
||||
newRight = Math.max(minRight, Math.min(maxRight, newRight));
|
||||
newBottom = Math.max(minBottom, Math.min(maxBottom, newBottom));
|
||||
|
||||
buttonPosition.value = {
|
||||
right: newRight,
|
||||
bottom: newBottom,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 触摸结束
|
||||
*/
|
||||
const handleTouchEnd = () => {
|
||||
if (isDragging.value) {
|
||||
saveButtonPosition();
|
||||
}
|
||||
isDragging.value = false;
|
||||
};
|
||||
|
||||
// 组件挂载时加载位置
|
||||
onMounted(() => {
|
||||
loadButtonPosition();
|
||||
});
|
||||
|
||||
/**
|
||||
* 切换文件夹展开/收起
|
||||
*/
|
||||
const handleToggleFolder = (folderId: string) => {
|
||||
const newExpanded = new Set(expandedFolders.value);
|
||||
if (newExpanded.has(folderId)) {
|
||||
newExpanded.delete(folderId);
|
||||
} else {
|
||||
newExpanded.add(folderId);
|
||||
}
|
||||
expandedFolders.value = newExpanded;
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择文件
|
||||
*/
|
||||
const handleSelectFile = (fileNode: FileNode) => {
|
||||
selectedFileId.value = fileNode.id;
|
||||
if (fileNode?.isLink) {
|
||||
uni.showToast({
|
||||
title: t("Mobile.FileTree.linkFileNotSupported"),
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url:
|
||||
"/subpackages/pages/file-preview-page/file-preview-page?cId=" +
|
||||
props.cId +
|
||||
"&fileProxyUrl=" +
|
||||
encodeURIComponent(fileNode.fileProxyUrl),
|
||||
});
|
||||
};
|
||||
|
||||
// 打开文件树弹窗
|
||||
const open = () => {
|
||||
popupFileTreeRef.value?.open();
|
||||
};
|
||||
|
||||
// 关闭文件树弹窗
|
||||
const close = () => {
|
||||
popupFileTreeRef.value?.close();
|
||||
};
|
||||
|
||||
// 暴露文件树弹窗引用
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-tree {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: scroll;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
.file-tree-wrapper {
|
||||
display: inline-block;
|
||||
padding-bottom: 20rpx;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.test-long-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-tree-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
|
||||
.icon-loading-image {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
margin-right: 10rpx;
|
||||
-webkit-touch-callout: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.file-tree-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
position: fixed;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
|
||||
transition: background-color 0.2s;
|
||||
touch-action: none;
|
||||
|
||||
&:active {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.download-icon {
|
||||
font-size: 48rpx;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
// 导入所有文件图标
|
||||
import iconDefault from '@/static/filetree/icon_default.svg'
|
||||
import iconTsx from '@/static/filetree/icon_tsx.svg'
|
||||
import iconTs from '@/static/filetree/icon_ts.svg'
|
||||
import iconJs from '@/static/filetree/icon_js.svg'
|
||||
import iconCss from '@/static/filetree/icon_css.svg'
|
||||
import iconJson from '@/static/filetree/icon_json.svg'
|
||||
import iconHtml from '@/static/filetree/icon_html.svg'
|
||||
import iconMd from '@/static/filetree/icon_md.svg'
|
||||
import iconPy from '@/static/filetree/icon_py.svg'
|
||||
import iconSql from '@/static/filetree/icon_sql.svg'
|
||||
import iconPng from '@/static/filetree/icon_png.svg'
|
||||
import iconSvg from '@/static/filetree/icon_svg.svg'
|
||||
|
||||
/**
|
||||
* 根据文件名获取文件图标路径
|
||||
* @param fileName 文件名
|
||||
* @returns 图标路径
|
||||
*/
|
||||
export function getFileIcon(fileName: string): string {
|
||||
if (!fileName || fileName.length === 0) {
|
||||
return iconDefault
|
||||
}
|
||||
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || ''
|
||||
|
||||
// TypeScript 文件
|
||||
if (ext === 'tsx') {
|
||||
return iconTsx
|
||||
}
|
||||
if (ext === 'ts') {
|
||||
return iconTs
|
||||
}
|
||||
|
||||
// JavaScript 文件
|
||||
if (['js', 'jsx', 'mjs', 'cjs'].includes(ext)) {
|
||||
return iconJs
|
||||
}
|
||||
|
||||
// CSS 文件
|
||||
if (['css', 'less', 'scss', 'sass'].includes(ext)) {
|
||||
return iconCss
|
||||
}
|
||||
|
||||
// JSON 文件
|
||||
if (['json', 'jsonc'].includes(ext)) {
|
||||
return iconJson
|
||||
}
|
||||
|
||||
// HTML 文件
|
||||
if (['html', 'htm'].includes(ext)) {
|
||||
return iconHtml
|
||||
}
|
||||
|
||||
// Markdown 文件
|
||||
if (['md', 'markdown'].includes(ext)) {
|
||||
return iconMd
|
||||
}
|
||||
|
||||
// Python 文件
|
||||
if (ext === 'py') {
|
||||
return iconPy
|
||||
}
|
||||
|
||||
// SQL 文件
|
||||
if (['sql', 'mysql', 'pgsql'].includes(ext)) {
|
||||
return iconSql
|
||||
}
|
||||
|
||||
// 图片文件
|
||||
if (ext === 'png') {
|
||||
return iconPng
|
||||
}
|
||||
if (ext === 'svg') {
|
||||
return iconSvg
|
||||
}
|
||||
if (['jpg', 'jpeg', 'gif', 'bmp', 'webp', 'ico', 'tiff'].includes(ext)) {
|
||||
return iconPng
|
||||
}
|
||||
|
||||
// 默认文件图标
|
||||
return iconDefault
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<view class="more-info-container">
|
||||
<!-- 头像区域 -->
|
||||
<view class="avatar-section">
|
||||
<view class="avatar-wrapper">
|
||||
<image
|
||||
class="avatar"
|
||||
:src="agentInfo?.icon"
|
||||
mode="aspectFill"
|
||||
:alt="t('Mobile.Common.avatarAlt')"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 标题区域 -->
|
||||
<view class="title-section">
|
||||
<text class="title text-ellipsis">{{ agentInfo?.name }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 作者信息 -->
|
||||
<view class="author-section">
|
||||
<text class="author-label">{{ t("Mobile.Common.from") }}</text>
|
||||
<view class="author-avatar">
|
||||
<text class="author-initial">{{ authorInitial }}</text>
|
||||
</view>
|
||||
<text class="author-name">{{
|
||||
agentInfo?.publishUser?.nickName || agentInfo?.publishUser?.userName
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 统计数据 -->
|
||||
<view class="stats-section">
|
||||
<view class="stat-item">
|
||||
<text class="iconfont icon-User" />
|
||||
<text class="stat-number">{{ agentInfo?.statistics?.userCount }}</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="iconfont icon-message" />
|
||||
<text class="stat-number">{{ agentInfo?.statistics?.convCount }}</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="iconfont icon-Star" />
|
||||
<text class="stat-number">{{
|
||||
agentInfo?.statistics?.collectCount
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 描述文本 -->
|
||||
<view class="description-section">
|
||||
<text class="description-text">
|
||||
{{ agentInfo?.description }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts" setup>
|
||||
import type { AgentDetailDto } from "@/types/interfaces/agent";
|
||||
import { useI18n } from "@/utils/i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
// 组件逻辑
|
||||
interface Props {
|
||||
agentInfo: AgentDetailDto;
|
||||
}
|
||||
|
||||
// 接收组件属性,设置默认值
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const authorInitial = computed(() => {
|
||||
const name =
|
||||
props.agentInfo?.publishUser?.nickName ||
|
||||
props.agentInfo?.publishUser?.userName ||
|
||||
"";
|
||||
const first = String(name).trim().charAt(0);
|
||||
return first || "#";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 变量定义
|
||||
$primary-color: #15171f;
|
||||
$secondary-color: rgba(21, 23, 31, 0.7);
|
||||
$muted-color: rgba(21, 23, 31, 0.5);
|
||||
$accent-color: #6366f1;
|
||||
$bg-primary: #fff;
|
||||
$bg-avatar: #fff3cd;
|
||||
$bg-author: #dadbff;
|
||||
|
||||
// 阴影层级
|
||||
$shadow-light: 0 2rpx 8rpx rgba(232, 229, 255, 0.6);
|
||||
$shadow-heavy: 0 4rpx 12rpx rgba(255, 243, 205, 0.4);
|
||||
|
||||
.more-info-container {
|
||||
padding: 40rpx 32rpx;
|
||||
background-color: $bg-primary;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-radius: 16rpx;
|
||||
|
||||
.avatar-section {
|
||||
margin-bottom: 32rpx;
|
||||
|
||||
.avatar-wrapper {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $bg-avatar;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
box-shadow: $shadow-heavy;
|
||||
position: relative;
|
||||
|
||||
.avatar {
|
||||
width: 106%;
|
||||
height: 106%;
|
||||
position: absolute;
|
||||
left: -3%;
|
||||
top: -3%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title-section {
|
||||
margin-bottom: 24rpx;
|
||||
width: 100%;
|
||||
|
||||
.title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 600;
|
||||
color: $primary-color;
|
||||
line-height: 64rpx;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.author-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 32rpx;
|
||||
gap: 12rpx;
|
||||
|
||||
.author-label {
|
||||
font-size: 28rpx;
|
||||
line-height: 44rpx;
|
||||
color: $muted-color;
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $bg-author;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
box-shadow: $shadow-light;
|
||||
|
||||
.author-initial {
|
||||
font-size: 24rpx;
|
||||
color: $accent-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-size: 28rpx;
|
||||
line-height: 44rpx;
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 48rpx;
|
||||
margin-bottom: 40rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 24rpx 32rpx;
|
||||
border-radius: 12rpx;
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
|
||||
.iconfont {
|
||||
font-size: 32rpx;
|
||||
color: rgba(21, 23, 31, 0.5);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 32rpx;
|
||||
line-height: 32rpx;
|
||||
color: $muted-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.description-section {
|
||||
// max-width: 600rpx;
|
||||
|
||||
.description-text {
|
||||
font-size: 28rpx;
|
||||
line-height: 44rpx;
|
||||
color: $secondary-color;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<drawer-popup
|
||||
ref="popup"
|
||||
:title="t('Mobile.Conversation.more')"
|
||||
direction="bottom"
|
||||
:auto-height="true"
|
||||
default-height="15%"
|
||||
max-height="40%"
|
||||
>
|
||||
<view class="menu-list">
|
||||
<view class="menu-item" @click="handleCollect">
|
||||
<text class="iconfont icon-Star" v-if="!agentInfo?.collect" />
|
||||
<text class="iconfont icon-Star-fill" v-else />
|
||||
<text class="menu-text text-ellipsis">{{
|
||||
t("Mobile.Conversation.favorite")
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="handleShare">
|
||||
<text class="iconfont icon-Share" />
|
||||
<text class="menu-text text-ellipsis">{{
|
||||
t("Mobile.Conversation.share")
|
||||
}}</text>
|
||||
</view>
|
||||
<view
|
||||
class="menu-item"
|
||||
v-if="agentInfo?.paymentRequired === true && isSubscriptionEnabled"
|
||||
@click="handleMySubscription"
|
||||
>
|
||||
<text class="iconfont icon-Heart" />
|
||||
<text class="menu-text text-ellipsis">{{
|
||||
t("Mobile.Conversation.mySubscription")
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="handleMoreInfo">
|
||||
<text class="iconfont icon-Info" />
|
||||
<text class="menu-text text-ellipsis">{{
|
||||
t("Mobile.Conversation.moreInfo")
|
||||
}}</text>
|
||||
</view>
|
||||
<template v-if="isTaskAgent && hasHistory">
|
||||
<view class="menu-item" @click="handleRestartAgent">
|
||||
<text class="iconfont icon-restart" />
|
||||
<text class="menu-text text-ellipsis">{{
|
||||
t("Mobile.Sandbox.restartAgent")
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="handleRestartSandbox">
|
||||
<text class="iconfont icon-restart_agent" />
|
||||
<text class="menu-text text-ellipsis">{{
|
||||
currentSandboxId === "-1"
|
||||
? t("Mobile.Sandbox.restartCloudComp")
|
||||
: t("Mobile.Sandbox.restartClient")
|
||||
}}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</drawer-popup>
|
||||
|
||||
<drawer-popup
|
||||
ref="popupMore"
|
||||
direction="bottom"
|
||||
height="85vh"
|
||||
:show-header="true"
|
||||
>
|
||||
<more-info v-if="agentInfo" :agent-info="agentInfo" />
|
||||
<related-conversation
|
||||
v-if="agentInfo"
|
||||
:agent-info="agentInfo"
|
||||
@view-all="handleViewAll"
|
||||
/>
|
||||
</drawer-popup>
|
||||
<drawer-popup
|
||||
ref="popupViewAll"
|
||||
:title="t('Mobile.Conversation.allConversations')"
|
||||
direction="bottom"
|
||||
height="85vh"
|
||||
>
|
||||
<!-- 根据智能体id动态获取历史记录 -->
|
||||
<agent-conversation-history
|
||||
v-if="agentInfo"
|
||||
:agent-id="agentInfo.agentId"
|
||||
/>
|
||||
</drawer-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from "vue";
|
||||
import MoreInfo from "../more-info/more-info.uvue";
|
||||
import RelatedConversation from "../related-conversation/related-conversation.uvue";
|
||||
import type { AgentDetailDto } from "@/types/interfaces/agent";
|
||||
import type { ConversationInfo } from "@/types/interfaces/conversationInfo";
|
||||
import AgentConversationHistory from "../agent-conversation-history/agent-conversation-history.uvue";
|
||||
import { useI18n } from "@/utils/i18n";
|
||||
import { TENANT_CONFIG_INFO } from "@/constants/home.constants";
|
||||
import type { TenantConfigInfo } from "@/types/interfaces/login";
|
||||
import { SUCCESS_CODE } from "@/constants/codes.constants.uts";
|
||||
import { apiTenantConfig } from "@/servers/account";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface Props {
|
||||
agentInfo: AgentDetailDto;
|
||||
isTaskAgent?: boolean;
|
||||
hasHistory?: boolean;
|
||||
currentSandboxId?: string;
|
||||
}
|
||||
|
||||
// 接收组件属性,设置默认值
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isTaskAgent: false,
|
||||
hasHistory: false,
|
||||
currentSandboxId: "",
|
||||
});
|
||||
|
||||
// 是否开启订阅
|
||||
const isSubscriptionEnabled = ref<boolean>(false);
|
||||
|
||||
const checkSubscriptionEnable = async () => {
|
||||
const tenantConfigInfoString = uni.getStorageSync(TENANT_CONFIG_INFO);
|
||||
if (tenantConfigInfoString) {
|
||||
try {
|
||||
const tenantConfig = JSON.parse(
|
||||
tenantConfigInfoString,
|
||||
) as TenantConfigInfo;
|
||||
isSubscriptionEnabled.value = tenantConfig.enableSubscription === 1;
|
||||
} catch (error) {
|
||||
console.error("解析租户配置失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiTenantConfig();
|
||||
if (res.code === SUCCESS_CODE && res.data != null) {
|
||||
const latestConfig = res.data!;
|
||||
isSubscriptionEnabled.value = latestConfig.enableSubscription === 1;
|
||||
uni.setStorageSync(TENANT_CONFIG_INFO, JSON.stringify(latestConfig));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("同步最新租户配置失败:", e);
|
||||
}
|
||||
};
|
||||
|
||||
checkSubscriptionEnable();
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits([
|
||||
"collect",
|
||||
"restartAgent",
|
||||
"restartSandbox",
|
||||
"mySubscription",
|
||||
]);
|
||||
|
||||
const popup = ref<any>(null);
|
||||
const popupMore = ref<any>(null);
|
||||
const popupViewAll = ref<any>(null);
|
||||
const chatList = ref<ConversationInfo[]>([]);
|
||||
|
||||
// 打开抽屉
|
||||
const open = () => {
|
||||
checkSubscriptionEnable();
|
||||
popup.value?.open();
|
||||
};
|
||||
|
||||
// 关闭抽屉
|
||||
const close = () => {
|
||||
popup.value?.close();
|
||||
};
|
||||
|
||||
// 收藏功能
|
||||
const handleCollect = () => {
|
||||
close();
|
||||
emit("collect");
|
||||
};
|
||||
|
||||
// 订阅功能
|
||||
const handleMySubscription = () => {
|
||||
close();
|
||||
emit("mySubscription");
|
||||
};
|
||||
|
||||
// 分享功能
|
||||
const handleShare = () => {
|
||||
// 获取当前页面URL
|
||||
let currentUrl = "";
|
||||
|
||||
// #ifdef H5
|
||||
currentUrl = window.location.href;
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序中获取当前页面路径
|
||||
const pages = getCurrentPages();
|
||||
const currentPage = pages[pages.length - 1];
|
||||
currentUrl = `/${currentPage.route}`;
|
||||
if (currentPage.options && Object.keys(currentPage.options).length > 0) {
|
||||
const params = Object.keys(currentPage.options)
|
||||
.map((key) => `${key}=${currentPage.options[key]}`)
|
||||
.join("&");
|
||||
currentUrl += `?${params}`;
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 复制到剪贴板
|
||||
uni.setClipboardData({
|
||||
data: currentUrl,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: t("Mobile.Link.copyAndOpen"),
|
||||
icon: "success",
|
||||
duration: 2000,
|
||||
});
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error("复制失败:", err);
|
||||
uni.showToast({
|
||||
title: t("Mobile.Common.copyFailed"),
|
||||
icon: "error",
|
||||
duration: 2000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
// 更多信息功能
|
||||
const handleMoreInfo = () => {
|
||||
close();
|
||||
popupMore.value?.open();
|
||||
};
|
||||
|
||||
// 重启智能体
|
||||
const handleRestartAgent = () => {
|
||||
close();
|
||||
emit("restartAgent");
|
||||
};
|
||||
|
||||
// 重启容器环境
|
||||
const handleRestartSandbox = () => {
|
||||
close();
|
||||
emit("restartSandbox");
|
||||
};
|
||||
const handleViewAll = (data: ConversationInfo[]) => {
|
||||
chatList.value = data;
|
||||
popupViewAll.value?.open();
|
||||
};
|
||||
|
||||
onLoad(() => {});
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
popupMore,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-list {
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 20rpx 30rpx;
|
||||
transition: background-color 0.2s;
|
||||
border-radius: 8rpx;
|
||||
|
||||
&:active {
|
||||
background-color: rgba(12, 20, 102, 0.04);
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 40rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.icon-Star-fill {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
margin-left: 24rpx;
|
||||
font-size: 32rpx;
|
||||
line-height: 48rpx;
|
||||
color: rgba(21, 23, 31, 0.7);
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,443 @@
|
||||
<template>
|
||||
<view class="variables-box flex flex-col" v-if="variables?.length">
|
||||
<!-- header -->
|
||||
<view class="header">
|
||||
<text class="title">{{ t("Mobile.Conversation.settingsTitle") }}</text>
|
||||
<text class="iconfont icon-a-Chevrondown down-icon" :class="{'rotate-90': !isOpen}" @click="handleOpenClose" />
|
||||
</view>
|
||||
<!-- 内容区域 -->
|
||||
<view :class="{'close-form': !isOpen, 'conversation-container': isOpen}">
|
||||
<view class="form-box">
|
||||
<view class="uni-form-item" v-for="item in variables" :key="item.key">
|
||||
<!-- 名称 -->
|
||||
<view class="form-item-title">
|
||||
<text class="display-name">{{ item.displayName }}</text>
|
||||
<text v-if="item.require" class="required">{{
|
||||
t("Mobile.Common.requiredMark")
|
||||
}}</text>
|
||||
</view>
|
||||
<!-- textarea -->
|
||||
<textarea
|
||||
v-if="item.inputType === InputTypeEnum.Paragraph"
|
||||
:placeholder="item.description || t('Mobile.Common.pleaseInput')"
|
||||
class="uni-input"
|
||||
:class="{'disabled-style': disabled}"
|
||||
:disabled="disabled"
|
||||
:value="formSourceData[item.name]"
|
||||
placeholder-class="uni-placeholder"
|
||||
:maxlength="-1"
|
||||
@input="(e) => handleChangeInput(e, item)" />
|
||||
<!-- 级联下拉选择器(单选、多选) -->
|
||||
<view
|
||||
v-else-if="item.inputType === InputTypeEnum.Select || item.inputType === InputTypeEnum.MultipleSelect"
|
||||
class="uni-input flex flex-row items-center content-between"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<scroll-view
|
||||
v-if="formSourceData[item.name]?.length"
|
||||
class="flex-1 flex flex-row items-center"
|
||||
direction="horizontal"
|
||||
:scroll-with-animation="true"
|
||||
:show-scrollbar="false"
|
||||
scroll-x>
|
||||
<!-- 循环渲染 -->
|
||||
<template v-for="(info, index) in formSourceData[item.name]" :key="info">
|
||||
<!-- 多选样式 -->
|
||||
<view class="multiple-box" v-if="item.inputType === InputTypeEnum.MultipleSelect">
|
||||
<text :class="{'disabled-style': disabled}">{{`${info?.label || info}`}}</text>
|
||||
</view>
|
||||
<!-- 单选样式 -->
|
||||
<template v-else>
|
||||
<text :class="{'disabled-style': disabled}">{{`${info?.label || info}`}}</text>
|
||||
<text
|
||||
:class="{'disabled-style': disabled}"
|
||||
v-if="index !== formSourceData[item.name].length - 1"
|
||||
>
|
||||
{{ t("Mobile.Common.optionSeparator") }}
|
||||
</text>
|
||||
</template>
|
||||
</template>
|
||||
</scroll-view>
|
||||
<text v-else class="uni-placeholder">{{
|
||||
t("Mobile.Common.pleaseSelect")
|
||||
}}</text>
|
||||
<text class="iconfont icon-a-Chevrondown down-icon ml-10" />
|
||||
</view>
|
||||
<!-- 输入框 -->
|
||||
<input
|
||||
v-else
|
||||
:disabled="disabled"
|
||||
:value="formSourceData[item.name]"
|
||||
:placeholder="item.description || t('Mobile.Common.pleaseInput')"
|
||||
:type="item.inputType === InputTypeEnum.Number ? 'number' : 'text'"
|
||||
class="uni-input"
|
||||
:class="{'disabled-style': disabled}"
|
||||
placeholder-class="uni-placeholder"
|
||||
@input="(e) => handleChangeInput(e, item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="disabled" class="desc-box">
|
||||
<text class="desc">{{
|
||||
t("Mobile.Conversation.settingsLocked")
|
||||
}}</text>
|
||||
</view>
|
||||
<!-- 单选级联选择器 -->
|
||||
<l-cascader
|
||||
v-if="visible"
|
||||
:visible="visible"
|
||||
v-model="cascaderValue"
|
||||
:title="t('Mobile.Common.pleaseSelect')"
|
||||
:options="options"
|
||||
@close="handleCancelSelect"
|
||||
@change="onChange" />
|
||||
|
||||
<!-- 多选级联选择器 -->
|
||||
<l-cascader
|
||||
v-if="visibleMultiple"
|
||||
:visible="visibleMultiple"
|
||||
v-model:multipleValue="cascaderMultipleValue"
|
||||
:multiple="isMultiple"
|
||||
:title="t('Mobile.Common.pleaseSelect')"
|
||||
:options="options"
|
||||
:confirmBtn="t('Mobile.Common.ok')"
|
||||
@close="handleCancelSelect"
|
||||
@finish="handleFinish" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { BindConfigWithSub } from '@/types/interfaces/common';
|
||||
import { InputTypeEnum } from '@/types/enums/agent';
|
||||
import lCascader from '@/uni_modules/lime-cascader/components/l-cascader/l-cascader.uvue';
|
||||
import { useI18n } from "@/utils/i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
variables: {
|
||||
type: Array as PropType<BindConfigWithSub[]>,
|
||||
default: () => [],
|
||||
},
|
||||
userFillVariables: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
isFilled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 是否打开表单
|
||||
const isOpen = ref<boolean>(true);
|
||||
// 是否显示单选级联选择器
|
||||
const visible = ref<boolean>(false);
|
||||
// 是否显示多选级联选择器
|
||||
const visibleMultiple = ref<boolean>(false);
|
||||
// 是否多选
|
||||
const isMultiple = ref<boolean>(false);
|
||||
// 级联选择器选项
|
||||
const options = ref<any[]>([]);
|
||||
// 已选择项的值(单选模式)
|
||||
const cascaderValue = ref<string>('');
|
||||
// 已选择项的值(多选模式)
|
||||
const cascaderMultipleValue = ref<string[]>([]);
|
||||
// 当前选择项的名称
|
||||
const currentName = ref<string>('');
|
||||
// 表单数据
|
||||
const formData = reactive({});
|
||||
// 已选择项的源数据
|
||||
const formSourceData = reactive({});
|
||||
|
||||
const emit = defineEmits(['toggleWholeDisabled'])
|
||||
|
||||
defineExpose({
|
||||
variableParams: formData
|
||||
})
|
||||
|
||||
/**
|
||||
* 监听智能体用户已填写的变量参数
|
||||
*/
|
||||
watch(() => props.userFillVariables, (newVal) => {
|
||||
// 判断newVal是否为空对象
|
||||
const isEmptyObject = newVal && typeof newVal === 'object' && Object.keys(newVal).length === 0
|
||||
|
||||
if (isEmptyObject) {
|
||||
// 如果是空对象,清空formSourceData的所有属性,保持响应性
|
||||
for (const key in formSourceData) {
|
||||
delete formSourceData[key]
|
||||
}
|
||||
|
||||
// 如果是空对象,清空formData的所有属性,保持响应性
|
||||
for (const key in formData) {
|
||||
delete formData[key]
|
||||
}
|
||||
} else if (newVal && typeof newVal === 'object') {
|
||||
// 如果不是空对象,更新formSourceData
|
||||
Object.assign(formSourceData, newVal)
|
||||
Object.assign(formData, newVal)
|
||||
} else {
|
||||
// 如果newVal为null或undefined,也清空formSourceData
|
||||
for (const key in formSourceData) {
|
||||
delete formSourceData[key]
|
||||
}
|
||||
|
||||
// 如果是空对象,清空formData的所有属性,保持响应性
|
||||
for (const key in formData) {
|
||||
delete formData[key]
|
||||
}
|
||||
}
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
// 必填变量参数name列表
|
||||
const requiredNameList = computed(() => {
|
||||
return props.variables
|
||||
?.filter((item: BindConfigWithSub) => !item.systemVariable && item.require)
|
||||
?.map((item: BindConfigWithSub) => item.name) || []
|
||||
})
|
||||
|
||||
// 监听必填参数列表和变量参数变化
|
||||
watch([requiredNameList, formData], () => {
|
||||
// 没有必填参数时,不禁用发送按钮
|
||||
if (requiredNameList.value?.length > 0) {
|
||||
let isSameName = true
|
||||
for (const item of requiredNameList.value) {
|
||||
if (!formData[item] || formData[item]?.length === 0) {
|
||||
isSameName = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 未填写必填参数,禁用发送按钮
|
||||
emit('toggleWholeDisabled', !isSameName)
|
||||
}
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
/**
|
||||
* 级联选择器选择后
|
||||
* @param selectedPaths 选中的路径数组 输出: [['440000', '440300', '440305'], ['440000', '440300', '440306']]
|
||||
* @param selectedOptions 选中的源数据 // 输出:
|
||||
* [
|
||||
* { value: '440305', label: '南山区', ... },
|
||||
* { value: '440306', label: '龙岗区', ... }
|
||||
* ]
|
||||
*/
|
||||
const handleFinish = (selectedPaths: string[][], selectedOptions: UTSJSONObject[]) => {
|
||||
// 输出: [['440000', '440300', '440305'], ['440000', '440300', '440306']]
|
||||
formData[currentName.value] = selectedPaths || []
|
||||
formSourceData[currentName.value] = selectedOptions || []
|
||||
visibleMultiple.value = false
|
||||
isMultiple.value = false
|
||||
}
|
||||
|
||||
// 打开关闭表单
|
||||
const handleOpenClose = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
// 级联选择器选择后(单选模式)
|
||||
const onChange = (_: string | string[], selectedOptions: any[]) => {
|
||||
if (!isMultiple.value) {
|
||||
visible.value = false
|
||||
// 单选模式:value 是 string,selectedOptions 是选中的选项数组
|
||||
formData[currentName.value] = selectedOptions?.map(item => item.value) || []
|
||||
formSourceData[currentName.value] = selectedOptions || []
|
||||
}
|
||||
}
|
||||
|
||||
// 点击后打开级联选择器
|
||||
const handleSelect = (item) => {
|
||||
// 如果禁用,则不打开级联选择器
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
// 当前选择项的名称
|
||||
currentName.value = item.name
|
||||
if (item.inputType === InputTypeEnum.MultipleSelect) {
|
||||
visibleMultiple.value = true
|
||||
} else {
|
||||
// 显示级联选择器
|
||||
visible.value = true
|
||||
}
|
||||
// 是否多选
|
||||
isMultiple.value = item.inputType === InputTypeEnum.MultipleSelect
|
||||
// 下拉选择项配置
|
||||
options.value = item.selectConfig?.options || []
|
||||
// 已选择项的源数据中获取当前项的值
|
||||
const currentList = formSourceData[item.name] || []
|
||||
|
||||
if (isMultiple.value) {
|
||||
// 多选模式:初始化多选值
|
||||
if (currentList.length === 0) {
|
||||
cascaderMultipleValue.value = []
|
||||
} else {
|
||||
cascaderMultipleValue.value = currentList.map((item: any) => item.value || '')
|
||||
}
|
||||
} else {
|
||||
// 单选模式:初始化单选值
|
||||
if (currentList.length === 0) {
|
||||
cascaderValue.value = ''
|
||||
} else {
|
||||
const lastItem = currentList[currentList.length - 1]
|
||||
cascaderValue.value = lastItem.value || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 取消级联选择
|
||||
const handleCancelSelect = () => {
|
||||
// 当前选择项的名称
|
||||
currentName.value = ''
|
||||
// 隐藏级联选择器
|
||||
visible.value = false
|
||||
visibleMultiple.value = false
|
||||
isMultiple.value = false
|
||||
// 下拉选择项配置
|
||||
options.value = [];
|
||||
// 已选择项的值
|
||||
cascaderValue.value = '';
|
||||
cascaderMultipleValue.value = [];
|
||||
}
|
||||
|
||||
// 输入框输入值
|
||||
const handleChangeInput = (event: UniInputChangeEvent, item) => {
|
||||
formData[item.name] = event.detail.value
|
||||
formSourceData[item.name] = event.detail.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.variables-box {
|
||||
max-height: 50%;
|
||||
border-radius: 10rpx;
|
||||
background-color: #fff;
|
||||
border: 1rpx solid #e5e5e5;
|
||||
box-sizing: border-box;
|
||||
margin: 0 32rpx;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: bold;
|
||||
padding: 18rpx 20rpx;
|
||||
|
||||
.title {
|
||||
font-size: 28rpx;
|
||||
color: #15171F;
|
||||
}
|
||||
}
|
||||
|
||||
.down-icon {
|
||||
color: #95979c;
|
||||
user-select: none;
|
||||
font-size: 32rpx;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
|
||||
&.rotate-90 {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1rpx solid #e5e5e5;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.close-form {
|
||||
transition: height 0.3s ease-in-out;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-box {
|
||||
flex: 1;
|
||||
max-height: 400rpx;
|
||||
overflow-y: auto;
|
||||
padding: 0 20rpx;
|
||||
|
||||
.uni-form-item {
|
||||
padding: 16rpx 0;
|
||||
|
||||
.form-item-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.display-name {
|
||||
font-size: 28rpx;
|
||||
color: #15171F;
|
||||
}
|
||||
|
||||
.required {
|
||||
margin-left: 4rpx;
|
||||
color: #FF0000;
|
||||
}
|
||||
}
|
||||
|
||||
.uni-input {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
line-height: 40rpx;
|
||||
background-color: rgba(12,20,40,0.04);
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
color: #15171F;
|
||||
padding: 16rpx;
|
||||
margin-top: 8rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 禁用样式
|
||||
.disabled-style {
|
||||
background-color: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.uni-placeholder {
|
||||
color: #6b6b75;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.multiple-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
padding: 4rpx 12rpx;
|
||||
margin-right: 16rpx;
|
||||
border-radius: 8rpx;
|
||||
|
||||
.disabled-style {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.desc-box {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 70rpx;
|
||||
padding: 0 20rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.desc {
|
||||
color: #e5e5e5;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<view class="related-conversation">
|
||||
<view class="header">
|
||||
<text class="title">{{ t("Mobile.Conversation.relatedTitle") }}</text>
|
||||
<view class="view-all" @click="onViewAll(chatList)">
|
||||
<text class="view-all-text">{{
|
||||
t("Mobile.Conversation.viewAll")
|
||||
}}</text>
|
||||
<text class="iconfont icon-a-Chevronright" />
|
||||
</view>
|
||||
</view>
|
||||
<custom-nav-bar-speak :chat-list="chatList?.slice(0, 10)" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import type { ConversationInfo } from "@/types/interfaces/conversationInfo";
|
||||
import type { AgentDetailDto } from "@/types/interfaces/agent";
|
||||
import { SUCCESS_CODE } from "@/constants/codes.constants";
|
||||
import { apiAgentConversationList } from "@/servers/conversation";
|
||||
import CustomNavBarSpeak from "@/components/custom-nav-bar/custom-nav-bar-speak/custom-nav-bar-speak.uvue";
|
||||
import { useI18n } from "@/utils/i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface Props {
|
||||
agentInfo: AgentDetailDto;
|
||||
}
|
||||
|
||||
// 接收组件属性,设置默认值
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
// 定义自定义事件
|
||||
const emit = defineEmits<{
|
||||
// 查看全部事件
|
||||
"view-all": [type: string];
|
||||
}>();
|
||||
|
||||
const chatList = ref<ConversationInfo[]>([]);
|
||||
|
||||
// 查看全部处理
|
||||
const onViewAll = (data: ConversationInfo[]) => {
|
||||
emit("view-all", data);
|
||||
};
|
||||
|
||||
// 查询用户历史会话
|
||||
const fetchUserConversationList = async () => {
|
||||
const { code, data } = await apiAgentConversationList({
|
||||
agentId: props.agentInfo.agentId,
|
||||
});
|
||||
if (code === SUCCESS_CODE) {
|
||||
chatList.value = data || [];
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchUserConversationList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.related-conversation {
|
||||
padding: 0rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
.header {
|
||||
margin-top: 20rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 10rpx;
|
||||
.title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: rgba(21, 23, 31, 1);
|
||||
line-height: 44rpx;
|
||||
}
|
||||
.view-all {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
.view-all-text {
|
||||
font-size: 24rpx;
|
||||
color: rgba(130, 136, 148, 1);
|
||||
margin-right: 5rpx;
|
||||
line-height: 38rpx;
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 26rpx;
|
||||
color: #898a8e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<modal-popup ref="modal" :title="title" :enable-swipe-close="false">
|
||||
<template #header-extra>
|
||||
<view class="copy-btn" @tap="copy">
|
||||
<text class="iconfont icon-CopyOutlined"></text>
|
||||
</view>
|
||||
</template>
|
||||
<view class="details-content">
|
||||
<mp-html :content="formattedDetailData" :markdown="true" :show-header="false"></mp-html>
|
||||
</view>
|
||||
</modal-popup>
|
||||
</template>
|
||||
|
||||
<script lang="uts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { t } from '@/utils/i18n';
|
||||
import modalPopup from "@/components/modal-popup/modal-popup.uvue";
|
||||
import mpHtml from '@/subpackages/uni_modules/mp-html/components/mp-html/mp-html.vue'
|
||||
|
||||
/**
|
||||
* 工具详情弹窗组件
|
||||
*/
|
||||
defineOptions({
|
||||
name: "ToolDetailsModal"
|
||||
})
|
||||
|
||||
// 定义组件属性
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string,
|
||||
detailData?: any
|
||||
}>(), {
|
||||
title: "",
|
||||
detailData: null
|
||||
})
|
||||
|
||||
// 引用子组件
|
||||
const modal = ref<any>(null);
|
||||
|
||||
// 计算属性:格式化详情展示
|
||||
const formattedDetailData = computed(() : string => {
|
||||
const data = props.detailData;
|
||||
if (data == null) {
|
||||
return "";
|
||||
}
|
||||
let content = '';
|
||||
try {
|
||||
content = JSON.stringify(data, null, 2);
|
||||
} catch (e) {
|
||||
content = String(data);
|
||||
}
|
||||
return `\`\`\`json\n${content}\n\`\`\``;
|
||||
})
|
||||
|
||||
/**
|
||||
* 复制数据到剪贴板
|
||||
*/
|
||||
const copy = () => {
|
||||
const data = props.detailData;
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
let content = '';
|
||||
try {
|
||||
content = JSON.stringify(data, null, 2);
|
||||
} catch (e) {
|
||||
content = String(data);
|
||||
}
|
||||
|
||||
uni.setClipboardData({
|
||||
data: content,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: t('Mobile.Common.copySuccess'),
|
||||
icon: "none",
|
||||
});
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({
|
||||
title: t('Mobile.Common.copyFailed'),
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开弹窗
|
||||
*/
|
||||
const open = () => {
|
||||
modal.value?.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
*/
|
||||
const close = () => {
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
// 暴露组件方法
|
||||
defineExpose({
|
||||
open,
|
||||
close
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.copy-btn {
|
||||
margin: 0 25rpx;
|
||||
.iconfont {
|
||||
color: #333;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.details-content {
|
||||
/* 可以在这里添加额外的样式 */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
import { ref } from "vue";
|
||||
import type {
|
||||
AgentDetailDto,
|
||||
AgentManualComponentInfo,
|
||||
GuidQuestionDto,
|
||||
FileNode,
|
||||
} from "@/types/interfaces/agent";
|
||||
import { BindConfigWithSub } from "@/types/interfaces/common";
|
||||
import {
|
||||
MessageInfo,
|
||||
ConversationInfo,
|
||||
ProcessingInfo,
|
||||
OpenAppParams,
|
||||
} from "@/types/interfaces/conversationInfo";
|
||||
import { SandboxInfo } from "@/types/interfaces/sandbox";
|
||||
|
||||
/**
|
||||
* 数据层:负责状态管理和数据存储
|
||||
*/
|
||||
export default class AgentDetailData {
|
||||
// 基础信息
|
||||
agentId = ref<number | null>(null);
|
||||
agentName = ref<string>("");
|
||||
agentInfo = ref<AgentDetailDto | null>(null);
|
||||
conversationId = ref<number | null>(null);
|
||||
// 是否是临时会话
|
||||
isTempChat = ref<boolean>(false);
|
||||
// 首页页面URL
|
||||
pageHomeUrl = ref<string>("");
|
||||
// 首页页面弹窗
|
||||
popupPage = ref<any>(null);
|
||||
|
||||
// 消息相关
|
||||
messageList = ref<MessageInfo[]>([]);
|
||||
// 引导问题建议
|
||||
chatSuggestList = ref<string[] | GuidQuestionDto[]>([]);
|
||||
requestId = ref<string>("");
|
||||
messageIdRef = ref<string>("");
|
||||
// 当前会话消息请求ID
|
||||
currentConversationRequestId = ref<string>("");
|
||||
// 停止操作是否正在进行中
|
||||
isStoppingConversation = ref<boolean>(false);
|
||||
|
||||
// 会话状态
|
||||
conversationInfo = ref<ConversationInfo | null>(null);
|
||||
// 是否用户问题建议
|
||||
isSuggest = ref<boolean>(false);
|
||||
// 是否需要更新主题
|
||||
needUpdateTopicRef = ref<boolean>(true);
|
||||
// 是否正在加载会话
|
||||
isLoadingConversation = ref<boolean>(false);
|
||||
// 会话是否正在进行中(有消息正在处理)
|
||||
isConversationActive = ref<boolean>(false);
|
||||
// 是否发送过消息,如果是,则禁用变量参数
|
||||
isSendMessageRef = ref<boolean>(false);
|
||||
// 是否禁用发送按钮
|
||||
wholeDisabled = ref<boolean>(false);
|
||||
|
||||
// ==================== 临时会话相关 ====================
|
||||
// 链接Key
|
||||
chatKey = ref<string>("");
|
||||
// 验证码参数
|
||||
captchaVerifyParam = ref<string>("");
|
||||
// 会话唯一标识
|
||||
// 会话唯一标识
|
||||
conversationUid = ref<string>("");
|
||||
|
||||
// ==================== 沙盒相关 ====================
|
||||
// 沙盒列表
|
||||
sandboxList = ref<SandboxInfo[]>([]);
|
||||
// 智能体选中的沙盒映射
|
||||
agentSelectedSandbox = ref<{ [key: string]: string }>({});
|
||||
// 当前选中的沙盒ID
|
||||
currentSandboxId = ref<string>("");
|
||||
// 是否允许切换沙盒 (当ID固定时为true)
|
||||
isSandboxSwitchDisabled = ref<boolean>(false);
|
||||
// 沙盒是否不可用 (当显示"电脑不可用"时为true)
|
||||
isSandboxUnavailable = ref<boolean>(false);
|
||||
// 沙盒不可用时的提示文案
|
||||
sandboxDisabledText = ref<string>("");
|
||||
|
||||
// ==================== 模型选择相关 ====================
|
||||
// 当前选中的模型ID
|
||||
currentModelId = ref<number>(0);
|
||||
// 当前选中的模型名称
|
||||
currentModelName = ref<string>("");
|
||||
// 模型选择弹窗是否可见
|
||||
modelSelectVisible = ref<boolean>(false);
|
||||
|
||||
// 组件和功能
|
||||
manualComponents = ref<AgentManualComponentInfo[]>([]);
|
||||
// 已选组件列表
|
||||
selectedComponents = ref<AgentSelectedComponentInfo[]>([]);
|
||||
// 变量参数
|
||||
variables = ref<BindConfigWithSub[]>([]);
|
||||
// 用户填充变量
|
||||
userFillVariables = ref<{ [key: string]: string | number }>({});
|
||||
processingList = ref<ProcessingInfo[]>([]);
|
||||
messageRefs = ref<{ [key: string]: any }>({});
|
||||
|
||||
// 滚动相关
|
||||
scrollIntoView = ref<string>("");
|
||||
scrollInBottom = ref<boolean>(true);
|
||||
scrollWithAnimation = ref<boolean>(false);
|
||||
scrollTouch = ref<boolean>(false);
|
||||
scrolling = ref<boolean>(false);
|
||||
keyboardHeight = ref<number>(0);
|
||||
|
||||
// 弹窗引用
|
||||
refMorePopup = ref<any>(null);
|
||||
refSubscriptionModal = ref<any>(null);
|
||||
|
||||
sseProcessor: any = null;
|
||||
autoToLastMsg = ref<boolean>(false);
|
||||
scrollingT = ref<number>(0);
|
||||
mouseScroll = ref<boolean>(false);
|
||||
needSetLockAutoToLastMsg = ref<boolean>(true);
|
||||
|
||||
// ==================== 任务型智能体文件列表相关 ====================
|
||||
// 文件列表
|
||||
fileList = ref<FileNode[]>([]);
|
||||
// 是否正在加载文件列表
|
||||
isLoadingFiles = ref<boolean>(false);
|
||||
|
||||
// ==================== 加载更多历史消息相关 ====================
|
||||
// 是否正在加载更多消息
|
||||
isLoadingMoreMessages = ref<boolean>(false);
|
||||
// 是否还有更多历史消息
|
||||
hasMoreMessages = ref<boolean>(false);
|
||||
|
||||
// ==================== 开放应用参数, 来自用户自定义url地址传入的参数params ====================
|
||||
appParams = ref<OpenAppParams | null>(null);
|
||||
// 如果没有携带message参数,则将其他参数(files、attachments、selectedComponents、skillIds、modelId等)保存到urlOtherParams中,用于后续首次发送消息时使用
|
||||
urlOtherParams = ref<{ [key: string]: string }>({});
|
||||
|
||||
// ==================== 订阅和付费限制相关 ====================
|
||||
// 是否需要付费/订阅锁定
|
||||
subscriptionRequired = ref<boolean>(false);
|
||||
// 付费/订阅弹窗可见性
|
||||
subscriptionModalVisible = ref<boolean>(false);
|
||||
// 是否允许开启订阅限制(根据租户配置中的 enableSubscription 是否等于 1 来决定)
|
||||
enableSubscription = ref<number>(0);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
MessageInfo,
|
||||
ProcessingInfo,
|
||||
} from "@/types/interfaces/conversationInfo";
|
||||
import { BindConfigWithSub } from "@/types/interfaces/common";
|
||||
import { MessageStatusEnum } from "@/types/enums/common";
|
||||
import AgentDetailData from "./AgentDetailData.uts";
|
||||
|
||||
/**
|
||||
* 工具层:提供通用的工具函数
|
||||
*/
|
||||
export default class AgentDetailUtils {
|
||||
/**
|
||||
* 根据datasList内容动态确定type类型
|
||||
*/
|
||||
static determineTypeFromData(dataList: any[][]): string {
|
||||
if (!dataList || dataList.length === 0) return "text";
|
||||
|
||||
const firstToken = dataList[0]?.[0];
|
||||
if (firstToken) {
|
||||
switch (firstToken.type) {
|
||||
case "code":
|
||||
return "code";
|
||||
case "table":
|
||||
return "table";
|
||||
case "list":
|
||||
return "list";
|
||||
case "heading":
|
||||
return "heading";
|
||||
case "blockquote":
|
||||
return "quote";
|
||||
case "hr":
|
||||
return "divider";
|
||||
case "math":
|
||||
return "math";
|
||||
case "paragraph":
|
||||
return "paragraph";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一的uniqueId
|
||||
*/
|
||||
static generateUniqueId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 15);
|
||||
return `markdown_${timestamp}_${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查会话是否正在进行中
|
||||
*/
|
||||
static checkConversationActive(
|
||||
messages: MessageInfo[],
|
||||
data: AgentDetailData,
|
||||
): void {
|
||||
const hasActiveMessage =
|
||||
(messages?.length &&
|
||||
messages.some(
|
||||
(message) =>
|
||||
message.status === MessageStatusEnum.Loading ||
|
||||
message.status === MessageStatusEnum.Incomplete,
|
||||
)) ||
|
||||
false;
|
||||
data.isConversationActive.value = hasActiveMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理处理中的消息列表
|
||||
*/
|
||||
// static handleChatProcessingList(_processingList: ProcessingInfo[], data: AgentDetailData): void {
|
||||
// const processedMap = new Map<string, ProcessingInfo>()
|
||||
|
||||
// _processingList.forEach((item) => {
|
||||
// const key = item.executeId || ''
|
||||
// const existing = processedMap.get(key)
|
||||
|
||||
// if (!existing) {
|
||||
// processedMap.set(key, item)
|
||||
// }
|
||||
// })
|
||||
|
||||
// const newProcessingList: ProcessingInfo[] = []
|
||||
// processedMap.forEach((value) => {
|
||||
// newProcessingList.push(value)
|
||||
// })
|
||||
// data.processingList.value = newProcessingList
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { MessageInfo } from '@/types/interfaces/conversationInfo'
|
||||
import { MessageTypeEnum } from '@/types/enums/agent'
|
||||
import { MessageStatusEnum } from '@/types/enums/common'
|
||||
import AgentDetailData from './AgentDetailData.uts'
|
||||
import AgentDetailUtils from './AgentDetailUtils.uts'
|
||||
|
||||
/**
|
||||
* 组件管理层:负责组件的引用管理和更新
|
||||
*/
|
||||
export default class ComponentManager {
|
||||
private data: AgentDetailData
|
||||
constructor(private data: AgentDetailData) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置消息组件引用
|
||||
*/
|
||||
setMessageRef(el: any, messageId: string): void {
|
||||
if (el) {
|
||||
this.data.messageRefs.value[messageId] = el
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除消息组件引用
|
||||
*/
|
||||
removeMessageRef(messageId: string): void {
|
||||
if (this.data.messageRefs.value[messageId]) {
|
||||
delete this.data.messageRefs.value[messageId]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新消息组件
|
||||
*/
|
||||
async updateMessageComponent(messageId: string, newData: any): Promise<void> {
|
||||
const messageRef = this.data.messageRefs.value[messageId]
|
||||
if (messageRef) {
|
||||
try {
|
||||
if (typeof messageRef.updateMessage === 'function') {
|
||||
await messageRef.updateMessage(newData)
|
||||
} else {
|
||||
// 备用方案:强制触发组件更新
|
||||
await nextTick()
|
||||
const message = this.data.messageList.value.find(msg => msg.id === messageId)
|
||||
if (message) {
|
||||
message.lastUpdateTime = Date.now()
|
||||
this.data.messageList.value = [...this.data.messageList.value]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`更新消息组件 ${messageId} 失败:`, error)
|
||||
// 错误恢复:使用备用方案
|
||||
try {
|
||||
await nextTick()
|
||||
const message = this.data.messageList.value.find(msg => msg.id === messageId)
|
||||
if (message) {
|
||||
message.lastUpdateTime = Date.now()
|
||||
this.data.messageList.value = [...this.data.messageList.value]
|
||||
}
|
||||
} catch (backupError) {
|
||||
console.error('备用方案也失败了:', backupError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据状态枚举确定状态
|
||||
*/
|
||||
determineState(status: MessageStatusEnum): number {
|
||||
switch (status) {
|
||||
case MessageStatusEnum.Loading:
|
||||
return 1
|
||||
case MessageStatusEnum.Incomplete:
|
||||
return 2
|
||||
case MessageStatusEnum.Complete:
|
||||
return 3
|
||||
case MessageStatusEnum.Error:
|
||||
return 4
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将MessageInfo转换为MsgItem格式
|
||||
*/
|
||||
convertMessageInfoToMsgItem(messageInfo: MessageInfo): any {
|
||||
return {
|
||||
_id: messageInfo.id?.toString() || '',
|
||||
from_uid: messageInfo.messageType === MessageTypeEnum.ASSISTANT ? 'uni-ai' : 'user',
|
||||
// thinkContent: messageInfo.text || messageInfo.think || '',
|
||||
// body: messageInfo.text || messageInfo.think || '',
|
||||
thinkContent: messageInfo.think || '',
|
||||
body: messageInfo.text || '',
|
||||
create_time: new Date(messageInfo.time || Date.now()).getTime(),
|
||||
state: this.determineState(messageInfo.status),
|
||||
chat_id: 'chat-' + this.data.conversationId.value,
|
||||
// markdownElList: [],
|
||||
// markdownElList: messageInfo.markdownElList || [],
|
||||
rendered: false,
|
||||
// 传递processingList用于自定义组件渲染(历史消息的工具调用等)
|
||||
processingList: messageInfo.processingList || []
|
||||
} as any
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
# Agent Detail 分层架构说明
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
pages/agent-detail/
|
||||
├── agent-detail.uvue # 主组件文件 (包含控制器逻辑和视图层逻辑)
|
||||
├── styles/ # 样式文件目录
|
||||
│ └── agent-detail.scss # 页面样式文件
|
||||
└── layers/ # 分层架构目录
|
||||
├── README.md # 本说明文档
|
||||
├── AgentDetailData.uts # 数据层
|
||||
├── AgentDetailUtils.uts # 工具层
|
||||
├── AgentDetailService.uts # 服务层
|
||||
├── ComponentManager.uts # 组件管理层
|
||||
└── ScrollManager.uts # 滚动管理层
|
||||
```
|
||||
|
||||
## 🏗️ 架构层次
|
||||
|
||||
### 1. 数据层 (Data Layer) - `AgentDetailData.uts`
|
||||
**职责**: 负责状态管理和数据存储
|
||||
- 管理所有响应式数据 (`ref`)
|
||||
- 存储组件引用和私有变量
|
||||
- 提供数据的统一访问接口
|
||||
|
||||
**特点**:
|
||||
- 纯数据类,不包含业务逻辑
|
||||
- 所有数据都是响应式的
|
||||
- 便于状态管理和调试
|
||||
|
||||
### 2. 工具层 (Utility Layer) - `AgentDetailUtils.uts`
|
||||
**职责**: 提供通用的工具函数
|
||||
- 类型判断和转换
|
||||
- ID生成和数据处理
|
||||
- 纯函数设计,无副作用
|
||||
|
||||
**特点**:
|
||||
- 静态方法,无需实例化
|
||||
- 易于测试和复用
|
||||
- 与业务逻辑解耦
|
||||
|
||||
### 3. 服务层 (Service Layer) - `AgentDetailService.uts`
|
||||
**职责**: 负责业务逻辑和API调用
|
||||
- 处理API请求和响应
|
||||
- 管理业务流程
|
||||
- 协调数据层和外部服务
|
||||
|
||||
**特点**:
|
||||
- 依赖注入数据层
|
||||
- 包含复杂的业务逻辑
|
||||
- 处理异步操作
|
||||
|
||||
### 4. 组件管理层 (Component Management Layer) - `ComponentManager.uts`
|
||||
**职责**: 负责组件的引用管理和更新
|
||||
- 管理子组件的引用
|
||||
- 处理组件间的通信
|
||||
- 提供组件更新接口
|
||||
|
||||
**特点**:
|
||||
- 管理组件生命周期
|
||||
- 处理组件间依赖关系
|
||||
- 提供组件更新策略
|
||||
|
||||
### 5. 滚动管理层 (Scroll Management Layer) - `ScrollManager.uts`
|
||||
**职责**: 专门处理滚动相关的逻辑
|
||||
- 管理滚动状态
|
||||
- 处理滚动事件
|
||||
- 控制自动滚动行为
|
||||
|
||||
**特点**:
|
||||
- 专注于滚动功能
|
||||
- 与业务逻辑解耦
|
||||
- 易于独立测试
|
||||
|
||||
### 6. 视图层 (View Layer) - `agent-detail.uvue`
|
||||
**职责**: 负责用户交互和事件处理
|
||||
- 处理用户操作
|
||||
- 协调各层之间的交互
|
||||
- 管理视图状态
|
||||
|
||||
**特点**:
|
||||
- 直接在主组件中实现
|
||||
- 处理用户交互逻辑
|
||||
- 协调各层功能
|
||||
|
||||
### 7. 主控制器 (Main Controller) - `agent-detail.uvue`
|
||||
**职责**: 协调各层之间的交互
|
||||
- 初始化各层实例
|
||||
- 管理依赖关系
|
||||
- 设置监听器和生命周期
|
||||
|
||||
**特点**:
|
||||
- 直接在主组件中实现
|
||||
- 管理各层生命周期
|
||||
- 便于查看和维护
|
||||
|
||||
## 🔄 数据流向
|
||||
|
||||
```
|
||||
用户操作 → View Layer (主组件) → Service → Data
|
||||
↓
|
||||
组件更新 ← ComponentManager ← Data
|
||||
↓
|
||||
滚动控制 ← ScrollManager ← Data
|
||||
```
|
||||
|
||||
## 💡 使用方式
|
||||
|
||||
### 在主组件中使用
|
||||
|
||||
```typescript
|
||||
// 1. 导入各层
|
||||
import AgentDetailData from './layers/AgentDetailData.uts'
|
||||
import AgentDetailService from './layers/AgentDetailService.uts'
|
||||
import ComponentManager from './layers/ComponentManager.uts'
|
||||
import ScrollManager from './layers/ScrollManager.uts'
|
||||
|
||||
// 2. 创建各层实例
|
||||
const data = new AgentDetailData()
|
||||
const service = new AgentDetailService(data)
|
||||
const componentManager = new ComponentManager(data)
|
||||
const scrollManager = new ScrollManager(data)
|
||||
|
||||
// 3. 直接在组件中实现视图层方法
|
||||
const handleMorePopup = (): void => {
|
||||
if (data.refMorePopup.value) {
|
||||
nextTick(() => {
|
||||
data.refMorePopup.value?.open()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 使用各层功能
|
||||
const messageList = data.messageList
|
||||
await service.handleSendMessage('Hello')
|
||||
componentManager.setMessageRef(el, 'msg-1')
|
||||
scrollManager.scrollToLastMsg(true)
|
||||
```
|
||||
|
||||
### 添加新功能
|
||||
|
||||
1. **新增数据**: 在 `AgentDetailData.uts` 中添加新的 `ref`
|
||||
2. **新增工具**: 在 `AgentDetailUtils.uts` 中添加新的静态方法
|
||||
3. **新增业务**: 在 `AgentDetailService.uts` 中添加新的方法
|
||||
4. **新增组件管理**: 在 `ComponentManager.uts` 中添加新的组件管理逻辑
|
||||
5. **新增滚动功能**: 在 `ScrollManager.uts` 中添加新的滚动控制
|
||||
6. **新增交互**: 直接在 `agent-detail.uvue` 中添加新的事件处理方法
|
||||
7. **新增控制器逻辑**: 直接在 `agent-detail.uvue` 中添加
|
||||
|
||||
## ✅ 架构优势
|
||||
|
||||
1. **职责分离**: 每个类都有明确的职责边界
|
||||
2. **依赖注入**: 通过构造函数注入依赖,降低耦合
|
||||
3. **易于测试**: 每个层都可以独立进行单元测试
|
||||
4. **代码复用**: 工具方法可以在其他地方复用
|
||||
5. **维护性提升**: 修改某个功能时,只需要关注对应的层
|
||||
6. **扩展性增强**: 新增功能时,可以轻松添加新的类或方法
|
||||
7. **便于维护**: 控制器逻辑和视图层逻辑直接在主组件中,方便查看和调试
|
||||
|
||||
## 🧪 测试策略
|
||||
|
||||
- **数据层**: 测试数据初始化和响应式更新
|
||||
- **工具层**: 测试工具函数的输入输出
|
||||
- **服务层**: 测试API调用和业务逻辑
|
||||
- **组件管理层**: 测试组件引用管理
|
||||
- **滚动管理层**: 测试滚动状态和事件
|
||||
- **视图层**: 测试用户交互处理(在主组件中)
|
||||
- **主控制器**: 测试各层协调和初始化(在主组件中)
|
||||
|
||||
## 📝 注意事项
|
||||
|
||||
1. **依赖关系**: 确保各层之间的依赖关系清晰
|
||||
2. **错误处理**: 在服务层和组件管理层添加适当的错误处理
|
||||
3. **性能优化**: 避免在数据层中存储过多数据
|
||||
4. **类型安全**: 使用TypeScript确保类型安全
|
||||
5. **文档更新**: 添加新功能时及时更新文档
|
||||
6. **控制器维护**: 控制器逻辑在主组件中,需要保持代码整洁
|
||||
7. **视图层维护**: 视图层方法直接在主组件中,便于调试和维护
|
||||
8. **样式维护**: 样式文件独立管理,便于主题定制和样式复用
|
||||
@@ -0,0 +1,121 @@
|
||||
import { nextTick } from "vue";
|
||||
import AgentDetailData from "./AgentDetailData.uts";
|
||||
|
||||
/**
|
||||
* 滚动管理层:专门处理滚动相关的逻辑
|
||||
* 兼容微信小程序和 H5/App
|
||||
*/
|
||||
export default class ScrollManager {
|
||||
private data: AgentDetailData;
|
||||
// 记录上次滚动位置,用于判断滚动方向
|
||||
private lastScrollTop: number = 0;
|
||||
|
||||
constructor(private data: AgentDetailData) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到最后一条消息
|
||||
* 使用 scroll-into-view 方式,兼容所有平台
|
||||
*/
|
||||
scrollToLastMsg(userClick: boolean): void {
|
||||
this.data.autoToLastMsg.value = true;
|
||||
this.data.scrollIntoView.value = "";
|
||||
this.data.scrollWithAnimation.value = userClick;
|
||||
nextTick(() => {
|
||||
this.data.scrollIntoView.value = "last-msg";
|
||||
this.data.scrollInBottom.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否在滚动底部
|
||||
* 使用 scroll 事件参数,兼容微信小程序
|
||||
*/
|
||||
setIsInScrollBottom(e: UniScrollEvent): void {
|
||||
nextTick(() => {
|
||||
// 使用事件参数获取滚动信息,兼容微信小程序
|
||||
const scrollTop = e?.detail?.scrollTop ?? 0;
|
||||
const scrollHeight = e?.detail?.scrollHeight ?? 0;
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序需要通过其他方式获取容器高度,这里使用估算值
|
||||
const offsetHeight = 600; // 默认视窗高度估算
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
const scrollList = uni.getElementById("msg-list");
|
||||
const offsetHeight = scrollList?.offsetHeight ?? 600;
|
||||
// #endif
|
||||
|
||||
const diff: number = scrollHeight - scrollTop - offsetHeight;
|
||||
const isInBottom = diff < 50;
|
||||
this.data.scrollInBottom.value = isInBottom;
|
||||
|
||||
// 如果滑到底部,自动恢复自动滚动
|
||||
if (isInBottom) {
|
||||
this.data.autoToLastMsg.value = true;
|
||||
}
|
||||
|
||||
// 只有在用户主动滚动(触摸或鼠标)时才可能禁用自动滚动
|
||||
// 且只在向上滚动(远离底部)时禁用
|
||||
const isScrollingUp = scrollTop < this.lastScrollTop;
|
||||
this.lastScrollTop = scrollTop;
|
||||
|
||||
if (
|
||||
(this.data.scrollTouch.value || this.data.mouseScroll.value) &&
|
||||
isScrollingUp && !isInBottom
|
||||
) {
|
||||
// 用户向上滚动时,立即禁用自动滚动,不需要等待滚动到一定距离
|
||||
// 这样用户可以更容易地中断快速消息流带来的自动滚动
|
||||
this.data.autoToLastMsg.value = false;
|
||||
// 同时显示"滚动到底部"按钮
|
||||
this.data.scrollInBottom.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定自动滚动到底部
|
||||
* 修复:不再过早禁用自动滚动,仅在特殊情况下处理
|
||||
*/
|
||||
lockAutoToLastMsg(): void {
|
||||
// 如果已经处理过或不需要锁定,直接返回
|
||||
if (!this.data.needSetLockAutoToLastMsg.value) return;
|
||||
|
||||
// 简化逻辑:只有当消息列表为空时才禁用
|
||||
// 其他情况保持 autoToLastMsg 的当前状态
|
||||
if (this.data.messageList.value.length === 0) {
|
||||
this.data.autoToLastMsg.value = false;
|
||||
this.data.needSetLockAutoToLastMsg.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动事件处理
|
||||
* 修复:不再错误地设置 mouseScroll,让触摸和鼠标滚动分开处理
|
||||
*/
|
||||
onScroll(e: UniScrollEvent): void {
|
||||
// mouseScroll 只在 H5 平台由 wheel 事件设置,这里不设置
|
||||
// 触摸滚动由 touchstart/touchend 事件控制 scrollTouch
|
||||
this.setIsInScrollBottom(e);
|
||||
this.data.scrolling.value = true;
|
||||
if (this.data.scrollingT.value != 0) {
|
||||
clearTimeout(this.data.scrollingT.value);
|
||||
}
|
||||
this.data.scrollingT.value = setTimeout(() => {
|
||||
this.data.scrolling.value = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到指定消息位置
|
||||
* 用于加载更多历史消息后保持滚动位置
|
||||
*/
|
||||
scrollToMessage(messageId: string | number): void {
|
||||
this.data.scrollWithAnimation.value = false;
|
||||
this.data.scrollIntoView.value = "";
|
||||
// 延迟设置,确保微信小程序 DOM 渲染完成后再触发定位
|
||||
setTimeout(() => {
|
||||
this.data.scrollIntoView.value = `msg-wrapper-${messageId}`;
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/* Agent Detail 页面样式 */
|
||||
|
||||
.agent-detail-container {
|
||||
display: flex;
|
||||
// padding-top: env(safe-area-inset-top);
|
||||
|
||||
.right-buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.icon-box {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12rpx;
|
||||
|
||||
&-hover {
|
||||
background-color: rgba(12, 20, 102, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 32rpx;
|
||||
overflow-y: auto;
|
||||
|
||||
.title-section {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
flex: 1;
|
||||
|
||||
.scroll-view-box {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
|
||||
.user-msg {
|
||||
font-size: 16px;
|
||||
margin-top: 16rpx;
|
||||
margin-left: auto;
|
||||
margin-right: 32rpx;
|
||||
max-width: 600rpx;
|
||||
padding: 32rpx;
|
||||
border-radius: 24rpx 0 24rpx 24rpx;
|
||||
background: rgba(12, 20, 102, 0.12);
|
||||
color: #15171f;
|
||||
// font-size: 28rpx;
|
||||
font-weight: 400;
|
||||
line-height: 48rpx;
|
||||
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
/* #ifdef WEB || MP-WEIXIN */
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
white-space: pre-wrap;
|
||||
/* #endif */
|
||||
}
|
||||
|
||||
.msg-list-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48rpx;
|
||||
padding: 16rpx 0;
|
||||
|
||||
.files-container {
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
padding: 0 32rpx;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
height: auto;
|
||||
min-height: 128rpx;
|
||||
|
||||
.files-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-end;
|
||||
gap: 10rpx;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.files-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// margin-right: 16rpx;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
&-plus {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
background: #f6f6f6;
|
||||
}
|
||||
|
||||
&-image {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
background: #f6f6f6;
|
||||
}
|
||||
|
||||
&-file {
|
||||
width: 400rpx;
|
||||
padding: 16rpx;
|
||||
background: rgba(12, 20, 102, 0.04);
|
||||
gap: 8rpx;
|
||||
overflow: hidden;
|
||||
|
||||
.doc-image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
.doc-info-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
font-weight: 400;
|
||||
|
||||
.doc-name {
|
||||
font-size: 28rpx;
|
||||
color: #15171f;
|
||||
}
|
||||
|
||||
.doc-size {
|
||||
font-size: 24rpx;
|
||||
color: #828894;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-to-bottom {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
border-radius: 50px;
|
||||
padding: 5px;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.3);
|
||||
z-index: 10;
|
||||
|
||||
/* #ifdef APP-HARMONY */
|
||||
border: 1px solid #efefef;
|
||||
/* #endif */
|
||||
|
||||
.iconfont {
|
||||
font-size: 40rpx;
|
||||
color: #555;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
qiming-mobile/subpackages/pages/chat-temp/chat-temp.uvue
Normal file
10
qiming-mobile/subpackages/pages/chat-temp/chat-temp.uvue
Normal 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>
|
||||
@@ -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>
|
||||
196
qiming-mobile/subpackages/pages/credit-records/styles/index.scss
Normal file
196
qiming-mobile/subpackages/pages/credit-records/styles/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
193
qiming-mobile/subpackages/pages/login-weixin/login-weixin.uvue
Normal file
193
qiming-mobile/subpackages/pages/login-weixin/login-weixin.uvue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
159
qiming-mobile/subpackages/pages/login/login.uvue
Normal file
159
qiming-mobile/subpackages/pages/login/login.uvue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
64
qiming-mobile/subpackages/pages/my-subscriptions/utils.uts
Normal file
64
qiming-mobile/subpackages/pages/my-subscriptions/utils.uts
Normal 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}`;
|
||||
}
|
||||
@@ -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>
|
||||
232
qiming-mobile/subpackages/pages/webview/webview.uvue
Normal file
232
qiming-mobile/subpackages/pages/webview/webview.uvue
Normal 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>
|
||||
52
qiming-mobile/subpackages/servers/sandbox.uts
Normal file
52
qiming-mobile/subpackages/servers/sandbox.uts
Normal 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",
|
||||
});
|
||||
};
|
||||
30
qiming-mobile/subpackages/servers/skill.uts
Normal file
30
qiming-mobile/subpackages/servers/skill.uts
Normal 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
|
||||
});
|
||||
}
|
||||
92
qiming-mobile/subpackages/servers/subscription.uts
Normal file
92
qiming-mobile/subpackages/servers/subscription.uts
Normal 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,
|
||||
});
|
||||
}
|
||||
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_AMS-Regular.ttf
Normal file
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_AMS-Regular.ttf
Normal file
Binary file not shown.
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Main-Bold.ttf
Normal file
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Main-Bold.ttf
Normal file
Binary file not shown.
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Main-BoldItalic.ttf
Normal file
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Main-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Main-Italic.ttf
Normal file
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Main-Italic.ttf
Normal file
Binary file not shown.
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Main-Regular.ttf
Normal file
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Main-Regular.ttf
Normal file
Binary file not shown.
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Math-BoldItalic.ttf
Normal file
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Math-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Math-Italic.ttf
Normal file
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Math-Italic.ttf
Normal file
Binary file not shown.
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_SansSerif-Bold.ttf
Normal file
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_SansSerif-Bold.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Size1-Regular.ttf
Normal file
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Size1-Regular.ttf
Normal file
Binary file not shown.
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Size2-Regular.ttf
Normal file
BIN
qiming-mobile/subpackages/static/fonts/KaTeX_Size2-Regular.ttf
Normal file
Binary file not shown.
4
qiming-mobile/subpackages/types/enums/chat.uts
Normal file
4
qiming-mobile/subpackages/types/enums/chat.uts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum ChatInputMethod {
|
||||
Voice = 'voice',
|
||||
Input = 'input',
|
||||
}
|
||||
150
qiming-mobile/subpackages/types/interfaces/subscription.uts
Normal file
150
qiming-mobile/subpackages/types/interfaces/subscription.uts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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(/"/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>
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Container 插件
|
||||
*/
|
||||
function Container(vm) {}
|
||||
|
||||
export default Container;
|
||||
@@ -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] || {};
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
copyByLongPress: true, // 是否需要长按代码块时显示复制代码内容菜单
|
||||
showLanguageName: true, // 是否在代码块右上角显示语言的名称
|
||||
showLineNumber: true, // 是否显示行号
|
||||
};
|
||||
@@ -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(/&/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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
7
qiming-mobile/subpackages/uni_modules/mp-html/components/mp-html/highlight/prism.min.js
vendored
Normal file
7
qiming-mobile/subpackages/uni_modules/mp-html/components/mp-html/highlight/prism.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
1
qiming-mobile/subpackages/uni_modules/mp-html/components/mp-html/latex/katex.min.js
vendored
Normal file
1
qiming-mobile/subpackages/uni_modules/mp-html/components/mp-html/latex/katex.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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**​$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;
|
||||
@@ -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**​$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;
|
||||
6
qiming-mobile/subpackages/uni_modules/mp-html/components/mp-html/markdown/marked.min.js
vendored
Normal file
6
qiming-mobile/subpackages/uni_modules/mp-html/components/mp-html/markdown/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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(/&/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>
|
||||
@@ -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
@@ -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 */
|
||||
@@ -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
Reference in New Issue
Block a user