Преглед изворни кода

feat(zutai): 新增组态列表及内嵌预览功能

- 接入组态项目分页查询接口
- 实现组态名称搜索、分页加载、下拉刷新和缩略图展示
- 新增 WebView 组态预览页面
- 通过 fragment 传递登录态和租户信息
- 增加预览入口缓存刷新参数
- 注册组态列表及预览路由
- 补充开发和生产环境 Web 预览地址配置
Zimo пре 2 недеља
родитељ
комит
c96fb70833
6 измењених фајлова са 579 додато и 3 уклоњено
  1. 10 0
      api/webtopo.js
  2. 2 1
      config/env.dev.js
  3. 2 2
      config/env.prod.js
  4. 13 0
      pages.json
  5. 405 0
      pages/zutai/index.vue
  6. 147 0
      pages/zutai/preview.vue

+ 10 - 0
api/webtopo.js

@@ -0,0 +1,10 @@
+import { request } from "@/utils/request";
+
+const baseUrl = "/pms/iot-webtopo-project";
+
+export const getWebtopoProjectPage = (params) =>
+  request({
+    url: `${baseUrl}/page`,
+    method: "get",
+    data: params,
+  });

+ 2 - 1
config/env.dev.js

@@ -1,8 +1,9 @@
 // 开发环境配置
 export default {
   apiUrl: "https://iot.deepoil.cc:5443",
+  // 本地组态预览服务;电脑局域网 IP 变化后需要同步更新
+  webUrl: "https://iot.deepoil.cc:5443",
   // apiUrl: "http://192.168.188.86:48080",
-  // apiUrl: "https://aims.deepoil.cc", //正式
   apiUrlSuffix: "/admin-api",
   // 其他开发环境配置...
   corpId: "dingbe7f9a7e8cffa2bd35c2f4657eb6378f", //钉钉微应用 企业的CorpID - 正式环境

+ 2 - 2
config/env.prod.js

@@ -1,8 +1,8 @@
 // 生产环境配置
 export default {
   // apiUrl: 'http://192.168.1.63:8888',
-  // apiUrl: "https://aims.deepoil.cc", //测试
-  apiUrl: "https://iot.deepoil.cc:5443", //正式
+  apiUrl: "https://aims.deepoil.cc", //正式
+  webUrl: "https://aims.deepoil.cc",
   apiUrlSuffix: "/admin-api",
   // 其他开发环境配置...
   corpId: "dingbe7f9a7e8cffa2bd35c2f4657eb6378f", //钉钉微应用 企业的CorpID - 正式环境

+ 13 - 0
pages.json

@@ -15,6 +15,19 @@
 				"navigationStyle": "custom"
 			}
 		},
+		{
+			"path": "pages/zutai/index",
+			"style": {
+				"navigationBarTitleText": "组态管理",
+				"enablePullDownRefresh": true
+			}
+		},
+		{
+			"path": "pages/zutai/preview",
+			"style": {
+				"navigationBarTitleText": "组态预览"
+			}
+		},
 		{
 			// 首页
 			"path": "pages/home/index",

+ 405 - 0
pages/zutai/index.vue

@@ -0,0 +1,405 @@
+<template>
+  <view class="webtopo-page">
+    <view class="search-panel">
+      <view class="search-box">
+        <uni-icons type="search" size="18" color="#94a3b8" />
+        <input
+          v-model.trim="projectName"
+          class="search-input"
+          confirm-type="search"
+          placeholder="请输入组态名称"
+          @confirm="search"
+        />
+        <uni-icons
+          v-if="projectName"
+          type="clear"
+          size="18"
+          color="#94a3b8"
+          @click="clearSearch"
+        />
+      </view>
+      <button class="search-button" @click="search">查询</button>
+    </view>
+
+    <view class="list-header">
+      <view>
+        <view class="list-title">组态项目</view>
+        <view class="list-subtitle">共 {{ total }} 个项目</view>
+      </view>
+      <view class="refresh-button" @click="refreshList">
+        <uni-icons type="refreshempty" size="18" color="#2563eb" />
+        <text>刷新</text>
+      </view>
+    </view>
+
+    <view v-if="loading" class="state-panel">
+      <uni-load-more status="loading" />
+    </view>
+
+    <view v-else-if="!list.length" class="state-panel empty-panel">
+      <view class="empty-icon">
+        <uni-icons type="image" size="34" color="#94a3b8" />
+      </view>
+      <view class="empty-title">暂无组态项目</view>
+      <view class="empty-description">请尝试调整搜索条件后重新查询</view>
+    </view>
+
+    <view v-else class="project-list">
+      <view
+        v-for="item in list"
+        :key="item.id"
+        class="project-card"
+        @click="openPreview(item)"
+      >
+        <view class="thumbnail-wrap">
+          <image
+            v-if="item.thumbnail && !thumbnailErrors[item.id]"
+            class="thumbnail"
+            :src="item.thumbnail"
+            mode="aspectFill"
+            @error="markThumbnailError(item.id)"
+          />
+          <view v-else class="thumbnail-placeholder">
+            <uni-icons type="image" size="38" color="#94a3b8" />
+            <text>暂无缩略图</text>
+          </view>
+          <view class="device-badge">
+            <uni-icons type="gear" size="14" color="#ffffff" />
+            <text>{{ getLinkedDeviceCount(item) }} 台设备</text>
+          </view>
+        </view>
+
+        <view class="project-content">
+          <view class="project-name">{{
+            item.projectName || "未命名组态"
+          }}</view>
+          <view class="project-meta">
+            <uni-icons type="calendar" size="15" color="#94a3b8" />
+            <text>{{ formatCreateTime(item.createTime) }}</text>
+          </view>
+          <view class="project-remark">{{ item.remark || "暂无备注" }}</view>
+        </view>
+
+        <view class="card-footer">
+          <text>查看组态</text>
+          <uni-icons type="right" size="16" color="#2563eb" />
+        </view>
+      </view>
+    </view>
+
+    <uni-load-more
+      v-if="list.length"
+      :status="loadStatus"
+      :content-text="loadText"
+      @clickLoadMore="loadMore"
+    />
+  </view>
+</template>
+
+<script setup>
+import { computed, reactive, ref } from "vue";
+import { onLoad, onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
+import dayjs from "dayjs";
+import { getWebtopoProjectPage } from "@/api/webtopo";
+import { getDeptId } from "@/utils/auth";
+
+const projectName = ref("");
+const list = ref([]);
+const total = ref(0);
+const pageNo = ref(1);
+const pageSize = 10;
+const loading = ref(false);
+const loadingMore = ref(false);
+const thumbnailErrors = reactive({});
+
+const hasMore = computed(() => list.value.length < total.value);
+const loadStatus = computed(() =>
+  loadingMore.value ? "loading" : hasMore.value ? "more" : "noMore",
+);
+const loadText = {
+  contentdown: "上拉加载更多",
+  contentrefresh: "加载中",
+  contentnomore: "没有更多数据了",
+};
+
+const fetchList = async (append = false) => {
+  append ? (loadingMore.value = true) : (loading.value = true);
+  try {
+    const response = await getWebtopoProjectPage({
+      pageNo: pageNo.value,
+      pageSize,
+      deptId: getDeptId() || undefined,
+      projectName: projectName.value || undefined,
+    });
+    const pageData = response?.data;
+    const rows = Array.isArray(pageData) ? pageData : pageData?.list || [];
+    list.value = append ? [...list.value, ...rows] : rows;
+    total.value = Array.isArray(pageData)
+      ? pageData.length
+      : Number(pageData?.total || 0);
+  } finally {
+    loading.value = false;
+    loadingMore.value = false;
+  }
+};
+
+const search = () => {
+  pageNo.value = 1;
+  fetchList();
+};
+
+const clearSearch = () => {
+  projectName.value = "";
+  search();
+};
+
+const refreshList = async () => {
+  pageNo.value = 1;
+  await fetchList();
+};
+
+const loadMore = () => {
+  if (!hasMore.value || loadingMore.value) return;
+  pageNo.value += 1;
+  fetchList(true);
+};
+
+const getLinkedDeviceCount = (item) =>
+  item.linkedDeviceIds?.length || item.linkedDevices?.length || 0;
+
+const formatCreateTime = (value) => {
+  if (!value) return "创建时间未知";
+  const time =
+    typeof value === "number" && String(value).length === 10
+      ? value * 1000
+      : value;
+  return dayjs(time).format("YYYY-MM-DD HH:mm");
+};
+
+const markThumbnailError = (id) => {
+  thumbnailErrors[id] = true;
+};
+
+const openPreview = (item) => {
+  if (!item?.id) return;
+  uni.navigateTo({
+    url: `/pages/zutai/preview?id=${encodeURIComponent(item.id)}&name=${encodeURIComponent(
+      item.projectName || "组态预览",
+    )}`,
+  });
+};
+
+onLoad(() => fetchList());
+onReachBottom(loadMore);
+onPullDownRefresh(async () => {
+  await refreshList();
+  uni.stopPullDownRefresh();
+});
+</script>
+
+<style lang="scss" scoped>
+.webtopo-page {
+  min-height: 100vh;
+  padding: 24rpx;
+  box-sizing: border-box;
+  background: #f4f7fb;
+}
+
+.search-panel {
+  display: flex;
+  padding: 22rpx;
+  background: #ffffff;
+  border-radius: 20rpx;
+  box-shadow: 0 8rpx 28rpx rgba(30, 64, 175, 0.05);
+  align-items: center;
+}
+
+.search-box {
+  display: flex;
+  height: 72rpx;
+  padding: 0 20rpx;
+  background: #f4f7fb;
+  border: 1rpx solid #e2e8f0;
+  border-radius: 14rpx;
+  flex: 1;
+  align-items: center;
+}
+
+.search-input {
+  height: 72rpx;
+  margin: 0 14rpx;
+  color: #172033;
+  font-size: 28rpx;
+  flex: 1;
+}
+
+.search-button {
+  width: 128rpx;
+  height: 72rpx;
+  margin: 0 0 0 16rpx;
+  color: #ffffff;
+  font-size: 28rpx;
+  line-height: 72rpx;
+  background: #2563eb;
+  border-radius: 14rpx;
+}
+
+.list-header {
+  display: flex;
+  margin: 34rpx 6rpx 20rpx;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.list-title {
+  color: #172033;
+  font-size: 32rpx;
+  font-weight: 700;
+}
+
+.list-subtitle {
+  margin-top: 5rpx;
+  color: #94a3b8;
+  font-size: 23rpx;
+}
+
+.refresh-button {
+  display: flex;
+  padding: 14rpx 20rpx;
+  color: #2563eb;
+  font-size: 25rpx;
+  background: #eaf2ff;
+  border-radius: 28rpx;
+  align-items: center;
+  gap: 8rpx;
+}
+
+.state-panel {
+  padding: 120rpx 0;
+  color: #94a3b8;
+  text-align: center;
+}
+
+.empty-icon {
+  display: flex;
+  width: 104rpx;
+  height: 104rpx;
+  margin: 0 auto 24rpx;
+  background: #eaf2ff;
+  border-radius: 50%;
+  align-items: center;
+  justify-content: center;
+}
+
+.empty-title {
+  color: #475569;
+  font-size: 29rpx;
+  font-weight: 600;
+}
+
+.empty-description {
+  margin-top: 12rpx;
+  font-size: 24rpx;
+}
+
+.project-card {
+  overflow: hidden;
+  background: #ffffff;
+  border: 1rpx solid #edf2f7;
+  border-radius: 22rpx;
+  box-shadow: 0 9rpx 30rpx rgba(30, 64, 175, 0.06);
+}
+
+.project-card + .project-card {
+  margin-top: 22rpx;
+}
+
+.thumbnail-wrap {
+  position: relative;
+  height: 340rpx;
+  overflow: hidden;
+  background: linear-gradient(135deg, #eef3f8 0%, #e2e8f0 100%);
+}
+
+.thumbnail {
+  width: 100%;
+  height: 100%;
+}
+
+.thumbnail-placeholder {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  color: #94a3b8;
+  font-size: 24rpx;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 12rpx;
+}
+
+.device-badge {
+  position: absolute;
+  top: 20rpx;
+  left: 20rpx;
+  display: flex;
+  padding: 10rpx 16rpx;
+  color: #ffffff;
+  font-size: 23rpx;
+  background: rgba(15, 23, 42, 0.7);
+  border-radius: 24rpx;
+  align-items: center;
+  gap: 7rpx;
+}
+
+.project-content {
+  padding: 26rpx 26rpx 22rpx;
+}
+
+.project-name {
+  overflow: hidden;
+  color: #172033;
+  font-size: 32rpx;
+  font-weight: 700;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.project-meta {
+  display: flex;
+  margin-top: 18rpx;
+  color: #64748b;
+  font-size: 24rpx;
+  align-items: center;
+  gap: 9rpx;
+}
+
+.project-remark {
+  display: -webkit-box;
+  overflow: hidden;
+  min-height: 68rpx;
+  margin-top: 17rpx;
+  color: #94a3b8;
+  font-size: 25rpx;
+  line-height: 34rpx;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.card-footer {
+  display: flex;
+  padding: 20rpx 26rpx;
+  color: #2563eb;
+  font-size: 26rpx;
+  font-weight: 600;
+  background: #f8fbff;
+  border-top: 1rpx solid #edf2f7;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 5rpx;
+}
+
+button::after {
+  border: 0;
+}
+</style>

+ 147 - 0
pages/zutai/preview.vue

@@ -0,0 +1,147 @@
+<template>
+  <view class="preview-page">
+    <view v-if="errorMessage" class="preview-state">
+      <view class="state-icon">
+        <uni-icons type="info" size="36" color="#ef4444" />
+      </view>
+      <view class="state-title">组态加载失败</view>
+      <view class="state-description">{{ errorMessage }}</view>
+      <button class="retry-button" @click="retry">重新加载</button>
+    </view>
+    <web-view
+      v-else-if="previewUrl"
+      :key="webviewKey"
+      :src="previewUrl"
+      @error="handleError"
+    />
+    <view v-else class="preview-state">
+      <uni-load-more status="loading" />
+      <view class="loading-text">正在打开组态预览</view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { onLoad } from "@dcloudio/uni-app";
+import config from "@/utils/config";
+import { getAccessToken, getTenantId } from "@/utils/auth";
+
+const previewUrl = ref("");
+const errorMessage = ref("");
+const webviewKey = ref(0);
+let projectId = "";
+
+const buildPreviewUrl = () => {
+  const token = getAccessToken();
+  if (!token) {
+    errorMessage.value = "登录状态已失效,请重新登录后再试";
+    return;
+  }
+
+  const webBaseUrl = (
+    config.default.webUrl ||
+    config.default.apiUrl ||
+    ""
+  ).replace(/\/$/, "");
+  if (!webBaseUrl) {
+    errorMessage.value = "未配置组态预览地址";
+    return;
+  }
+
+  const authParams = [
+    "appPreview=1",
+    `appAccessToken=${encodeURIComponent(token)}`,
+    `tenantId=${encodeURIComponent(String(getTenantId() || "1"))}`,
+  ].join("&");
+  const cacheKey = Date.now();
+  previewUrl.value = `${webBaseUrl}/maotu/preview/${encodeURIComponent(
+    projectId,
+  )}?appPreviewEntry=${cacheKey}#${authParams}`;
+};
+
+const handleError = () => {
+  errorMessage.value = "请检查网络连接或组态服务地址后重试";
+};
+
+const retry = () => {
+  errorMessage.value = "";
+  previewUrl.value = "";
+  webviewKey.value += 1;
+  buildPreviewUrl();
+};
+
+onLoad((options) => {
+  projectId = String(options?.id || "");
+  if (!projectId) {
+    errorMessage.value = "缺少组态项目 ID";
+    return;
+  }
+
+  if (options?.name) {
+    uni.setNavigationBarTitle({
+      title: decodeURIComponent(options.name),
+    });
+  }
+  buildPreviewUrl();
+});
+</script>
+
+<style lang="scss" scoped>
+.preview-page {
+  min-height: 100vh;
+  background: #0f172a;
+}
+
+.preview-state {
+  display: flex;
+  min-height: 100vh;
+  padding: 48rpx;
+  box-sizing: border-box;
+  text-align: center;
+  background: #f4f7fb;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+
+.state-icon {
+  display: flex;
+  width: 112rpx;
+  height: 112rpx;
+  margin-bottom: 28rpx;
+  background: #fee2e2;
+  border-radius: 50%;
+  align-items: center;
+  justify-content: center;
+}
+
+.state-title {
+  color: #172033;
+  font-size: 34rpx;
+  font-weight: 700;
+}
+
+.state-description,
+.loading-text {
+  margin-top: 16rpx;
+  color: #64748b;
+  font-size: 26rpx;
+  line-height: 40rpx;
+}
+
+.retry-button {
+  width: 240rpx;
+  height: 76rpx;
+  margin-top: 36rpx;
+  color: #ffffff;
+  font-size: 28rpx;
+  line-height: 76rpx;
+  background: #2563eb;
+  border-radius: 14rpx;
+}
+
+.retry-button::after {
+  border: 0;
+}
+</style>