Преглед на файлове

feat(oa): add done mine cc lists opening read-only OA detail

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu преди 2 месеца
родител
ревизия
29560b743c
променени са 4 файла, в които са добавени 1292 реда и са изтрити 21 реда
  1. 9 3
      mobile-h5/pages.json
  2. 489 6
      mobile-h5/pages/oa/cc/index.vue
  3. 414 6
      mobile-h5/pages/oa/done/index.vue
  4. 380 6
      mobile-h5/pages/oa/mine/index.vue

+ 9 - 3
mobile-h5/pages.json

@@ -87,7 +87,9 @@
       "style": {
         "navigationBarTitleText": "已办中心",
         "navigationBarBackgroundColor": "#ecfdf5",
-        "navigationBarTextStyle": "black"
+        "navigationBarTextStyle": "black",
+        "enablePullDownRefresh": true,
+        "backgroundTextStyle": "dark"
       }
     },
     {
@@ -95,7 +97,9 @@
       "style": {
         "navigationBarTitleText": "我发起的",
         "navigationBarBackgroundColor": "#ecfdf5",
-        "navigationBarTextStyle": "black"
+        "navigationBarTextStyle": "black",
+        "enablePullDownRefresh": true,
+        "backgroundTextStyle": "dark"
       }
     },
     {
@@ -103,7 +107,9 @@
       "style": {
         "navigationBarTitleText": "抄送我的",
         "navigationBarBackgroundColor": "#ecfdf5",
-        "navigationBarTextStyle": "black"
+        "navigationBarTextStyle": "black",
+        "enablePullDownRefresh": true,
+        "backgroundTextStyle": "dark"
       }
     },
     {

+ 489 - 6
mobile-h5/pages/oa/cc/index.vue

@@ -1,12 +1,495 @@
 <template>
-  <view class="page-shell" style="padding:24rpx">
-    <text>{{ title }}</text>
+  <view class="page">
+    <view class="toolbar">
+      <view class="toolbar-row">
+        <view class="search-box" :class="{ 'is-focus': appNameFocused }">
+          <view class="search-ico" aria-hidden="true" />
+          <input
+            class="search-input"
+            :value="appName"
+            confirm-type="search"
+            placeholder="搜索流程名称"
+            placeholder-class="search-ph"
+            @input="onAppNameInput"
+            @confirm="onSearchConfirm"
+            @focus="appNameFocused = true"
+            @blur="appNameFocused = false"
+          />
+          <view v-if="appName" class="search-clear" @click="clearAppName">
+            <text class="search-clear__x">×</text>
+          </view>
+        </view>
+      </view>
+      <view class="toolbar-row toolbar-row--sub">
+        <view class="search-box" :class="{ 'is-focus': initiatorFocused }">
+          <input
+            class="search-input"
+            :value="initiatorName"
+            confirm-type="search"
+            placeholder="搜索申请人"
+            placeholder-class="search-ph"
+            @input="onInitiatorInput"
+            @confirm="onSearchConfirm"
+            @focus="initiatorFocused = true"
+            @blur="initiatorFocused = false"
+          />
+          <view v-if="initiatorName" class="search-clear" @click="clearInitiator">
+            <text class="search-clear__x">×</text>
+          </view>
+        </view>
+      </view>
+      <view class="read-tabs">
+        <view
+          v-for="tab in readTabs"
+          :key="tab.value"
+          class="read-tab"
+          :class="{ 'is-active': readStatus === tab.value }"
+          @click="setReadStatus(tab.value)"
+        >
+          {{ tab.label }}
+        </view>
+      </view>
+    </view>
+
+    <view v-if="loading && !list.length" class="state state-loading">
+      <view class="pulse-dots" aria-hidden="true">
+        <view class="pulse-dot" />
+        <view class="pulse-dot" />
+        <view class="pulse-dot" />
+      </view>
+      <text class="state-loading__text">加载抄送…</text>
+    </view>
+
+    <view v-else-if="!list.length" class="oa-task-list__empty">暂无抄送</view>
+
+    <view v-else class="oa-task-list">
+      <OaTaskCard
+        v-for="row in list"
+        :key="row.id"
+        :title="cardTitle(row)"
+        :status-text="statusText(row.instanceStatus)"
+        :status-tone="statusTone(row.instanceStatus)"
+        :unread="!row.isRead"
+        :meta-lines="metaLines(row)"
+        :actions="cardActions"
+        @open="openView(row)"
+        @action="openView(row)"
+      />
+      <view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
+      <view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
+    </view>
   </view>
 </template>
+
 <script>
-export default {
-  data() {
-    return { title: '抄送我的' }
+  import OaTaskCard from '../../../components/OaTaskCard.vue'
+  import { fetchMarkOaCcTaskRead, fetchOaTasks } from '../../../utils/oa-api.js'
+  import { forceRelogin, getToken } from '../../../utils/auth.js'
+  import { stashOaDetailTitle } from '../../../utils/oa-h5.js'
+
+  const PAGE_SIZE = 20
+
+  const STATUS_MAP = {
+    pending: '待处理',
+    running: '进行中',
+    approved: '已同意',
+    rejected: '已拒绝',
+    completed: '已完成',
+    cancelled: '已撤销',
+    done: '已结束',
+    returned: '已打回',
+    transferred: '已转交'
+  }
+
+  export default {
+    components: { OaTaskCard },
+    data() {
+      return {
+        appName: '',
+        initiatorName: '',
+        readStatus: 'unread',
+        appNameFocused: false,
+        initiatorFocused: false,
+        list: [],
+        page: 1,
+        total: 0,
+        loading: false,
+        loadingMore: false,
+        hasMore: true,
+        openingId: '',
+        _searchTimer: null,
+        readTabs: [
+          { value: 'unread', label: '未读' },
+          { value: 'read', label: '已读' },
+          { value: 'all', label: '全部' }
+        ],
+        cardActions: [{ key: 'view', label: '查看', primary: true }]
+      }
+    },
+    onShow() {
+      if (!getToken()) {
+        forceRelogin('请先登录')
+        return
+      }
+      this.loadList(true)
+    },
+    onPullDownRefresh() {
+      this.loadList(true)
+        .catch(() => {})
+        .finally(() => {
+          uni.stopPullDownRefresh()
+        })
+    },
+    onReachBottom() {
+      if (!this.hasMore || this.loading || this.loadingMore) return
+      this.loadList(false)
+    },
+    onUnload() {
+      if (this._searchTimer) {
+        clearTimeout(this._searchTimer)
+        this._searchTimer = null
+      }
+    },
+    methods: {
+      cardTitle(row) {
+        return (row && (row.appName || row.title)) || '-'
+      },
+      statusText(s) {
+        if (!s) return '-'
+        return STATUS_MAP[s] || s
+      },
+      statusTone(s) {
+        if (s === 'pending' || s === 'running') return 'warn'
+        if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
+        if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
+        return 'info'
+      },
+      metaLines(row) {
+        return [
+          {
+            label: '申请人',
+            value: (row && row.initiatorUserName) || '-'
+          },
+          {
+            label: '申请时间',
+            value: (row && row.applyTime) || '-'
+          },
+          {
+            label: '阅读',
+            value: row && row.isRead ? '已读' : '未读'
+          }
+        ]
+      },
+      scheduleSearch() {
+        if (this._searchTimer) clearTimeout(this._searchTimer)
+        this._searchTimer = setTimeout(() => {
+          this.loadList(true)
+        }, 400)
+      },
+      onAppNameInput(e) {
+        this.appName = this._inputVal(e)
+        this.scheduleSearch()
+      },
+      onInitiatorInput(e) {
+        this.initiatorName = this._inputVal(e)
+        this.scheduleSearch()
+      },
+      _inputVal(e) {
+        if (e && e.detail && e.detail.value != null) return String(e.detail.value)
+        if (e && e.target) return String(e.target.value || '')
+        return ''
+      },
+      onSearchConfirm() {
+        if (this._searchTimer) {
+          clearTimeout(this._searchTimer)
+          this._searchTimer = null
+        }
+        this.loadList(true)
+      },
+      clearAppName() {
+        this.appName = ''
+        this.onSearchConfirm()
+      },
+      clearInitiator() {
+        this.initiatorName = ''
+        this.onSearchConfirm()
+      },
+      setReadStatus(value) {
+        if (this.readStatus === value) return
+        this.readStatus = value
+        this.loadList(true)
+      },
+      async openView(row) {
+        if (!row || this.openingId) return
+        const instanceId = String(row.instanceId || '').trim()
+        const taskId = String(row.id || '').trim()
+        if (!instanceId) {
+          uni.showToast({ title: '缺少实例 ID', icon: 'none' })
+          return
+        }
+        this.openingId = taskId || instanceId
+        try {
+          if (taskId && !row.isRead) {
+            try {
+              await fetchMarkOaCcTaskRead(taskId)
+              row.isRead = true
+            } catch (e) {
+              uni.showToast({
+                title: (e && e.message) || '标记已读失败',
+                icon: 'none'
+              })
+            }
+          }
+          stashOaDetailTitle(row.appName || row.title)
+          uni.navigateTo({
+            url:
+              '/pages/oa/webview/index?instanceId=' +
+              encodeURIComponent(instanceId) +
+              '&mode=view'
+          })
+        } finally {
+          this.openingId = ''
+        }
+      },
+      async loadList(reset) {
+        if (!getToken()) {
+          forceRelogin('请先登录')
+          return
+        }
+        const isReset = !!reset
+        if (isReset) {
+          this.page = 1
+          this.hasMore = true
+          this.loading = true
+        } else {
+          this.loadingMore = true
+        }
+        try {
+          const res = await fetchOaTasks('cc', {
+            appName: (this.appName || '').trim() || undefined,
+            initiatorName: (this.initiatorName || '').trim() || undefined,
+            readStatus: this.readStatus || 'unread',
+            page: this.page,
+            pageSize: PAGE_SIZE
+          })
+          const rows = (res && res.list) || []
+          const total = (res && res.total) != null ? Number(res.total) : 0
+          this.total = total
+          if (isReset) this.list = rows
+          else this.list = this.list.concat(rows)
+          const loaded = this.list.length
+          this.hasMore = loaded < total && rows.length > 0
+          if (rows.length > 0) this.page += 1
+        } catch (e) {
+          if (isReset) this.list = []
+          uni.showToast({
+            title: (e && e.message) || '加载失败',
+            icon: 'none'
+          })
+        } finally {
+          this.loading = false
+          this.loadingMore = false
+        }
+      }
+    }
   }
-}
 </script>
+
+<style scoped>
+  .page {
+    min-height: 100vh;
+    padding-bottom: 48rpx;
+    background: #f5f6f8;
+    box-sizing: border-box;
+  }
+
+  .toolbar {
+    position: sticky;
+    top: 0;
+    z-index: 10;
+    background: #fff;
+    padding-bottom: 16rpx;
+    border-bottom: 1rpx solid #f1f5f9;
+  }
+
+  .toolbar-row {
+    display: flex;
+    align-items: center;
+    gap: 16rpx;
+    padding: 20rpx 24rpx 0;
+    box-sizing: border-box;
+  }
+
+  .toolbar-row--sub {
+    padding-top: 12rpx;
+  }
+
+  .search-box {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+    gap: 16rpx;
+    height: 72rpx;
+    padding: 0 24rpx;
+    border-radius: 20rpx;
+    border: 1rpx solid #e8eaed;
+    background: #fafafa;
+    box-sizing: border-box;
+    transition:
+      border-color 0.15s,
+      background 0.15s,
+      box-shadow 0.15s;
+  }
+
+  .search-box.is-focus {
+    border-color: #cbd5e1;
+    background: #fff;
+    box-shadow: 0 0 0 6rpx rgba(148, 163, 184, 0.18);
+  }
+
+  .search-ico {
+    position: relative;
+    width: 26rpx;
+    height: 26rpx;
+    border: 3rpx solid #94a3b8;
+    border-radius: 50%;
+    box-sizing: border-box;
+    flex-shrink: 0;
+  }
+
+  .search-ico::after {
+    content: '';
+    position: absolute;
+    width: 12rpx;
+    height: 3rpx;
+    right: -8rpx;
+    bottom: -5rpx;
+    background: #94a3b8;
+    border-radius: 3rpx;
+    transform: rotate(45deg);
+  }
+
+  .search-input {
+    flex: 1;
+    height: 72rpx;
+    font-size: 28rpx;
+    color: #1f2937;
+    min-width: 0;
+  }
+
+  .search-ph {
+    color: #b0b7c3;
+  }
+
+  .search-clear {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 40rpx;
+    height: 40rpx;
+    border-radius: 50%;
+    background: #e5e7eb;
+    flex-shrink: 0;
+  }
+
+  .search-clear__x {
+    font-size: 28rpx;
+    line-height: 1;
+    color: #6b7280;
+    margin-top: -2rpx;
+  }
+
+  .read-tabs {
+    display: flex;
+    align-items: center;
+    gap: 12rpx;
+    padding: 16rpx 24rpx 0;
+    box-sizing: border-box;
+  }
+
+  .read-tab {
+    flex: 1;
+    height: 64rpx;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 14rpx;
+    font-size: 26rpx;
+    font-weight: 600;
+    color: #64748b;
+    background: #f8fafc;
+    border: 1rpx solid #e2e8f0;
+    box-sizing: border-box;
+  }
+
+  .read-tab.is-active {
+    color: #166534;
+    background: #ecfdf5;
+    border-color: #a7f3d0;
+  }
+
+  .read-tab:active {
+    opacity: 0.78;
+  }
+
+  .state {
+    padding: 80rpx 24rpx;
+    text-align: center;
+    color: #999;
+    font-size: 28rpx;
+  }
+
+  .state-loading {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 28rpx;
+    min-height: 50vh;
+    padding: 96rpx 32rpx;
+    box-sizing: border-box;
+  }
+
+  .pulse-dots {
+    display: flex;
+    align-items: center;
+    gap: 16rpx;
+  }
+
+  .pulse-dot {
+    width: 16rpx;
+    height: 16rpx;
+    border-radius: 50%;
+    background: var(--brand-green, #22c55e);
+    animation: oa-pulse-bounce 1.2s ease-in-out infinite;
+  }
+
+  .pulse-dot:nth-child(2) {
+    animation-delay: 0.15s;
+    opacity: 0.75;
+  }
+
+  .pulse-dot:nth-child(3) {
+    animation-delay: 0.3s;
+    opacity: 0.55;
+  }
+
+  .state-loading__text {
+    font-size: 26rpx;
+    font-weight: 500;
+    color: var(--text-muted, #5f7a6d);
+    line-height: 1.4;
+  }
+
+  @keyframes oa-pulse-bounce {
+    0%,
+    80%,
+    100% {
+      transform: translateY(0);
+    }
+    40% {
+      transform: translateY(-12rpx);
+    }
+  }
+</style>

+ 414 - 6
mobile-h5/pages/oa/done/index.vue

@@ -1,12 +1,420 @@
 <template>
-  <view class="page-shell" style="padding:24rpx">
-    <text>{{ title }}</text>
+  <view class="page">
+    <view class="toolbar">
+      <view class="toolbar-row">
+        <view class="search-box" :class="{ 'is-focus': appNameFocused }">
+          <view class="search-ico" aria-hidden="true" />
+          <input
+            class="search-input"
+            :value="appName"
+            confirm-type="search"
+            placeholder="搜索流程名称"
+            placeholder-class="search-ph"
+            @input="onAppNameInput"
+            @confirm="onSearchConfirm"
+            @focus="appNameFocused = true"
+            @blur="appNameFocused = false"
+          />
+          <view v-if="appName" class="search-clear" @click="clearAppName">
+            <text class="search-clear__x">×</text>
+          </view>
+        </view>
+      </view>
+      <view class="toolbar-row toolbar-row--sub">
+        <view class="search-box" :class="{ 'is-focus': initiatorFocused }">
+          <input
+            class="search-input"
+            :value="initiatorName"
+            confirm-type="search"
+            placeholder="搜索申请人"
+            placeholder-class="search-ph"
+            @input="onInitiatorInput"
+            @confirm="onSearchConfirm"
+            @focus="initiatorFocused = true"
+            @blur="initiatorFocused = false"
+          />
+          <view v-if="initiatorName" class="search-clear" @click="clearInitiator">
+            <text class="search-clear__x">×</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view v-if="loading && !list.length" class="state state-loading">
+      <view class="pulse-dots" aria-hidden="true">
+        <view class="pulse-dot" />
+        <view class="pulse-dot" />
+        <view class="pulse-dot" />
+      </view>
+      <text class="state-loading__text">加载已办…</text>
+    </view>
+
+    <view v-else-if="!list.length" class="oa-task-list__empty">暂无已办</view>
+
+    <view v-else class="oa-task-list">
+      <OaTaskCard
+        v-for="row in list"
+        :key="row.id"
+        :title="cardTitle(row)"
+        :status-text="statusText(row.instanceStatus)"
+        :status-tone="statusTone(row.instanceStatus)"
+        :meta-lines="metaLines(row)"
+        :actions="cardActions"
+        @open="openView(row)"
+        @action="openView(row)"
+      />
+      <view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
+      <view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
+    </view>
   </view>
 </template>
+
 <script>
-export default {
-  data() {
-    return { title: '已办中心' }
+  import OaTaskCard from '../../../components/OaTaskCard.vue'
+  import { fetchOaTasks } from '../../../utils/oa-api.js'
+  import { forceRelogin, getToken } from '../../../utils/auth.js'
+  import { stashOaDetailTitle } from '../../../utils/oa-h5.js'
+
+  const PAGE_SIZE = 20
+
+  const STATUS_MAP = {
+    pending: '待处理',
+    running: '进行中',
+    approved: '已同意',
+    rejected: '已拒绝',
+    completed: '已完成',
+    cancelled: '已撤销',
+    done: '已结束',
+    returned: '已打回',
+    transferred: '已转交'
+  }
+
+  export default {
+    components: { OaTaskCard },
+    data() {
+      return {
+        appName: '',
+        initiatorName: '',
+        appNameFocused: false,
+        initiatorFocused: false,
+        list: [],
+        page: 1,
+        total: 0,
+        loading: false,
+        loadingMore: false,
+        hasMore: true,
+        _searchTimer: null,
+        cardActions: [{ key: 'view', label: '查看', primary: true }]
+      }
+    },
+    onShow() {
+      if (!getToken()) {
+        forceRelogin('请先登录')
+        return
+      }
+      this.loadList(true)
+    },
+    onPullDownRefresh() {
+      this.loadList(true)
+        .catch(() => {})
+        .finally(() => {
+          uni.stopPullDownRefresh()
+        })
+    },
+    onReachBottom() {
+      if (!this.hasMore || this.loading || this.loadingMore) return
+      this.loadList(false)
+    },
+    onUnload() {
+      if (this._searchTimer) {
+        clearTimeout(this._searchTimer)
+        this._searchTimer = null
+      }
+    },
+    methods: {
+      cardTitle(row) {
+        return (row && (row.appName || row.title)) || '-'
+      },
+      statusText(s) {
+        if (!s) return '-'
+        return STATUS_MAP[s] || s
+      },
+      statusTone(s) {
+        if (s === 'pending' || s === 'running') return 'warn'
+        if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
+        if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
+        return 'info'
+      },
+      metaLines(row) {
+        return [
+          {
+            label: '申请人',
+            value: (row && row.initiatorUserName) || '-'
+          },
+          {
+            label: '申请时间',
+            value: (row && row.applyTime) || '-'
+          },
+          {
+            label: '当前节点',
+            value: (row && row.currentNodeName) || '-'
+          }
+        ]
+      },
+      scheduleSearch() {
+        if (this._searchTimer) clearTimeout(this._searchTimer)
+        this._searchTimer = setTimeout(() => {
+          this.loadList(true)
+        }, 400)
+      },
+      onAppNameInput(e) {
+        this.appName = this._inputVal(e)
+        this.scheduleSearch()
+      },
+      onInitiatorInput(e) {
+        this.initiatorName = this._inputVal(e)
+        this.scheduleSearch()
+      },
+      _inputVal(e) {
+        if (e && e.detail && e.detail.value != null) return String(e.detail.value)
+        if (e && e.target) return String(e.target.value || '')
+        return ''
+      },
+      onSearchConfirm() {
+        if (this._searchTimer) {
+          clearTimeout(this._searchTimer)
+          this._searchTimer = null
+        }
+        this.loadList(true)
+      },
+      clearAppName() {
+        this.appName = ''
+        this.onSearchConfirm()
+      },
+      clearInitiator() {
+        this.initiatorName = ''
+        this.onSearchConfirm()
+      },
+      openView(row) {
+        if (!row) return
+        const instanceId = String(row.instanceId || '').trim()
+        if (!instanceId) {
+          uni.showToast({ title: '缺少实例 ID', icon: 'none' })
+          return
+        }
+        stashOaDetailTitle(row.appName || row.title)
+        uni.navigateTo({
+          url:
+            '/pages/oa/webview/index?instanceId=' +
+            encodeURIComponent(instanceId) +
+            '&mode=view'
+        })
+      },
+      async loadList(reset) {
+        if (!getToken()) {
+          forceRelogin('请先登录')
+          return
+        }
+        const isReset = !!reset
+        if (isReset) {
+          this.page = 1
+          this.hasMore = true
+          this.loading = true
+        } else {
+          this.loadingMore = true
+        }
+        try {
+          const res = await fetchOaTasks('done', {
+            appName: (this.appName || '').trim() || undefined,
+            initiatorName: (this.initiatorName || '').trim() || undefined,
+            page: this.page,
+            pageSize: PAGE_SIZE
+          })
+          const rows = (res && res.list) || []
+          const total = (res && res.total) != null ? Number(res.total) : 0
+          this.total = total
+          if (isReset) this.list = rows
+          else this.list = this.list.concat(rows)
+          const loaded = this.list.length
+          this.hasMore = loaded < total && rows.length > 0
+          if (rows.length > 0) this.page += 1
+        } catch (e) {
+          if (isReset) this.list = []
+          uni.showToast({
+            title: (e && e.message) || '加载失败',
+            icon: 'none'
+          })
+        } finally {
+          this.loading = false
+          this.loadingMore = false
+        }
+      }
+    }
   }
-}
 </script>
+
+<style scoped>
+  .page {
+    min-height: 100vh;
+    padding-bottom: 48rpx;
+    background: #f5f6f8;
+    box-sizing: border-box;
+  }
+
+  .toolbar {
+    position: sticky;
+    top: 0;
+    z-index: 10;
+    background: #fff;
+    padding-bottom: 16rpx;
+    border-bottom: 1rpx solid #f1f5f9;
+  }
+
+  .toolbar-row {
+    display: flex;
+    align-items: center;
+    gap: 16rpx;
+    padding: 20rpx 24rpx 0;
+    box-sizing: border-box;
+  }
+
+  .toolbar-row--sub {
+    padding-top: 12rpx;
+  }
+
+  .search-box {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+    gap: 16rpx;
+    height: 72rpx;
+    padding: 0 24rpx;
+    border-radius: 20rpx;
+    border: 1rpx solid #e8eaed;
+    background: #fafafa;
+    box-sizing: border-box;
+    transition:
+      border-color 0.15s,
+      background 0.15s,
+      box-shadow 0.15s;
+  }
+
+  .search-box.is-focus {
+    border-color: #cbd5e1;
+    background: #fff;
+    box-shadow: 0 0 0 6rpx rgba(148, 163, 184, 0.18);
+  }
+
+  .search-ico {
+    position: relative;
+    width: 26rpx;
+    height: 26rpx;
+    border: 3rpx solid #94a3b8;
+    border-radius: 50%;
+    box-sizing: border-box;
+    flex-shrink: 0;
+  }
+
+  .search-ico::after {
+    content: '';
+    position: absolute;
+    width: 12rpx;
+    height: 3rpx;
+    right: -8rpx;
+    bottom: -5rpx;
+    background: #94a3b8;
+    border-radius: 3rpx;
+    transform: rotate(45deg);
+  }
+
+  .search-input {
+    flex: 1;
+    height: 72rpx;
+    font-size: 28rpx;
+    color: #1f2937;
+    min-width: 0;
+  }
+
+  .search-ph {
+    color: #b0b7c3;
+  }
+
+  .search-clear {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 40rpx;
+    height: 40rpx;
+    border-radius: 50%;
+    background: #e5e7eb;
+    flex-shrink: 0;
+  }
+
+  .search-clear__x {
+    font-size: 28rpx;
+    line-height: 1;
+    color: #6b7280;
+    margin-top: -2rpx;
+  }
+
+  .state {
+    padding: 80rpx 24rpx;
+    text-align: center;
+    color: #999;
+    font-size: 28rpx;
+  }
+
+  .state-loading {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 28rpx;
+    min-height: 50vh;
+    padding: 96rpx 32rpx;
+    box-sizing: border-box;
+  }
+
+  .pulse-dots {
+    display: flex;
+    align-items: center;
+    gap: 16rpx;
+  }
+
+  .pulse-dot {
+    width: 16rpx;
+    height: 16rpx;
+    border-radius: 50%;
+    background: var(--brand-green, #22c55e);
+    animation: oa-pulse-bounce 1.2s ease-in-out infinite;
+  }
+
+  .pulse-dot:nth-child(2) {
+    animation-delay: 0.15s;
+    opacity: 0.75;
+  }
+
+  .pulse-dot:nth-child(3) {
+    animation-delay: 0.3s;
+    opacity: 0.55;
+  }
+
+  .state-loading__text {
+    font-size: 26rpx;
+    font-weight: 500;
+    color: var(--text-muted, #5f7a6d);
+    line-height: 1.4;
+  }
+
+  @keyframes oa-pulse-bounce {
+    0%,
+    80%,
+    100% {
+      transform: translateY(0);
+    }
+    40% {
+      transform: translateY(-12rpx);
+    }
+  }
+</style>

+ 380 - 6
mobile-h5/pages/oa/mine/index.vue

@@ -1,12 +1,386 @@
 <template>
-  <view class="page-shell" style="padding:24rpx">
-    <text>{{ title }}</text>
+  <view class="page">
+    <view class="toolbar">
+      <view class="toolbar-row">
+        <view class="search-box" :class="{ 'is-focus': searchFocused }">
+          <view class="search-ico" aria-hidden="true" />
+          <input
+            class="search-input"
+            :value="appName"
+            confirm-type="search"
+            placeholder="搜索流程名称"
+            placeholder-class="search-ph"
+            @input="onSearchInput"
+            @confirm="onSearchConfirm"
+            @focus="searchFocused = true"
+            @blur="searchFocused = false"
+          />
+          <view v-if="appName" class="search-clear" @click="clearAppName">
+            <text class="search-clear__x">×</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view v-if="loading && !list.length" class="state state-loading">
+      <view class="pulse-dots" aria-hidden="true">
+        <view class="pulse-dot" />
+        <view class="pulse-dot" />
+        <view class="pulse-dot" />
+      </view>
+      <text class="state-loading__text">加载我发起的…</text>
+    </view>
+
+    <view v-else-if="!list.length" class="oa-task-list__empty">暂无发起记录</view>
+
+    <view v-else class="oa-task-list">
+      <OaTaskCard
+        v-for="row in list"
+        :key="row.id"
+        :title="cardTitle(row)"
+        :status-text="statusText(row.instanceStatus || row.status)"
+        :status-tone="statusTone(row.instanceStatus || row.status)"
+        :meta-lines="metaLines(row)"
+        :actions="cardActions"
+        @open="openView(row)"
+        @action="openView(row)"
+      />
+      <view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
+      <view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
+    </view>
   </view>
 </template>
+
 <script>
-export default {
-  data() {
-    return { title: '我发起的' }
+  import OaTaskCard from '../../../components/OaTaskCard.vue'
+  import { fetchOaTasks } from '../../../utils/oa-api.js'
+  import { forceRelogin, getToken } from '../../../utils/auth.js'
+  import { stashOaDetailTitle } from '../../../utils/oa-h5.js'
+
+  const PAGE_SIZE = 20
+
+  const STATUS_MAP = {
+    pending: '待处理',
+    running: '进行中',
+    approved: '已同意',
+    rejected: '已拒绝',
+    completed: '已完成',
+    cancelled: '已撤销',
+    done: '已结束',
+    returned: '已打回',
+    transferred: '已转交'
+  }
+
+  export default {
+    components: { OaTaskCard },
+    data() {
+      return {
+        appName: '',
+        searchFocused: false,
+        list: [],
+        page: 1,
+        total: 0,
+        loading: false,
+        loadingMore: false,
+        hasMore: true,
+        _searchTimer: null,
+        cardActions: [{ key: 'view', label: '查看', primary: true }]
+      }
+    },
+    onShow() {
+      if (!getToken()) {
+        forceRelogin('请先登录')
+        return
+      }
+      this.loadList(true)
+    },
+    onPullDownRefresh() {
+      this.loadList(true)
+        .catch(() => {})
+        .finally(() => {
+          uni.stopPullDownRefresh()
+        })
+    },
+    onReachBottom() {
+      if (!this.hasMore || this.loading || this.loadingMore) return
+      this.loadList(false)
+    },
+    onUnload() {
+      if (this._searchTimer) {
+        clearTimeout(this._searchTimer)
+        this._searchTimer = null
+      }
+    },
+    methods: {
+      cardTitle(row) {
+        return (row && (row.appName || row.title)) || '-'
+      },
+      statusText(s) {
+        if (!s) return '-'
+        return STATUS_MAP[s] || s
+      },
+      statusTone(s) {
+        if (s === 'pending' || s === 'running') return 'warn'
+        if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
+        if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
+        return 'info'
+      },
+      metaLines(row) {
+        return [
+          {
+            label: '申请时间',
+            value: (row && row.applyTime) || '-'
+          },
+          {
+            label: '当前节点',
+            value: (row && row.currentNodeName) || '-'
+          },
+          {
+            label: '当前审批人',
+            value: (row && row.currentApproverNames) || '-'
+          }
+        ]
+      },
+      onSearchInput(e) {
+        const v =
+          e && e.detail && e.detail.value != null
+            ? String(e.detail.value)
+            : e && e.target
+              ? String(e.target.value || '')
+              : ''
+        this.appName = v
+        if (this._searchTimer) clearTimeout(this._searchTimer)
+        this._searchTimer = setTimeout(() => {
+          this.loadList(true)
+        }, 400)
+      },
+      onSearchConfirm() {
+        if (this._searchTimer) {
+          clearTimeout(this._searchTimer)
+          this._searchTimer = null
+        }
+        this.loadList(true)
+      },
+      clearAppName() {
+        this.appName = ''
+        this.onSearchConfirm()
+      },
+      openView(row) {
+        if (!row) return
+        // mine 行是实例:Web 用 row.id;兼容 instanceId
+        const instanceId = String(row.instanceId || row.id || '').trim()
+        if (!instanceId) {
+          uni.showToast({ title: '缺少实例 ID', icon: 'none' })
+          return
+        }
+        stashOaDetailTitle(row.appName || row.title)
+        uni.navigateTo({
+          url:
+            '/pages/oa/webview/index?instanceId=' +
+            encodeURIComponent(instanceId) +
+            '&mode=view'
+        })
+      },
+      async loadList(reset) {
+        if (!getToken()) {
+          forceRelogin('请先登录')
+          return
+        }
+        const isReset = !!reset
+        if (isReset) {
+          this.page = 1
+          this.hasMore = true
+          this.loading = true
+        } else {
+          this.loadingMore = true
+        }
+        try {
+          const res = await fetchOaTasks('mine', {
+            appName: (this.appName || '').trim() || undefined,
+            page: this.page,
+            pageSize: PAGE_SIZE
+          })
+          const rows = (res && res.list) || []
+          const total = (res && res.total) != null ? Number(res.total) : 0
+          this.total = total
+          if (isReset) this.list = rows
+          else this.list = this.list.concat(rows)
+          const loaded = this.list.length
+          this.hasMore = loaded < total && rows.length > 0
+          if (rows.length > 0) this.page += 1
+        } catch (e) {
+          if (isReset) this.list = []
+          uni.showToast({
+            title: (e && e.message) || '加载失败',
+            icon: 'none'
+          })
+        } finally {
+          this.loading = false
+          this.loadingMore = false
+        }
+      }
+    }
   }
-}
 </script>
+
+<style scoped>
+  .page {
+    min-height: 100vh;
+    padding-bottom: 48rpx;
+    background: #f5f6f8;
+    box-sizing: border-box;
+  }
+
+  .toolbar {
+    position: sticky;
+    top: 0;
+    z-index: 10;
+    background: #fff;
+    padding-bottom: 16rpx;
+    border-bottom: 1rpx solid #f1f5f9;
+  }
+
+  .toolbar-row {
+    display: flex;
+    align-items: center;
+    gap: 16rpx;
+    padding: 20rpx 24rpx 0;
+    box-sizing: border-box;
+  }
+
+  .search-box {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+    gap: 16rpx;
+    height: 72rpx;
+    padding: 0 24rpx;
+    border-radius: 20rpx;
+    border: 1rpx solid #e8eaed;
+    background: #fafafa;
+    box-sizing: border-box;
+    transition:
+      border-color 0.15s,
+      background 0.15s,
+      box-shadow 0.15s;
+  }
+
+  .search-box.is-focus {
+    border-color: #cbd5e1;
+    background: #fff;
+    box-shadow: 0 0 0 6rpx rgba(148, 163, 184, 0.18);
+  }
+
+  .search-ico {
+    position: relative;
+    width: 26rpx;
+    height: 26rpx;
+    border: 3rpx solid #94a3b8;
+    border-radius: 50%;
+    box-sizing: border-box;
+    flex-shrink: 0;
+  }
+
+  .search-ico::after {
+    content: '';
+    position: absolute;
+    width: 12rpx;
+    height: 3rpx;
+    right: -8rpx;
+    bottom: -5rpx;
+    background: #94a3b8;
+    border-radius: 3rpx;
+    transform: rotate(45deg);
+  }
+
+  .search-input {
+    flex: 1;
+    height: 72rpx;
+    font-size: 28rpx;
+    color: #1f2937;
+    min-width: 0;
+  }
+
+  .search-ph {
+    color: #b0b7c3;
+  }
+
+  .search-clear {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 40rpx;
+    height: 40rpx;
+    border-radius: 50%;
+    background: #e5e7eb;
+    flex-shrink: 0;
+  }
+
+  .search-clear__x {
+    font-size: 28rpx;
+    line-height: 1;
+    color: #6b7280;
+    margin-top: -2rpx;
+  }
+
+  .state {
+    padding: 80rpx 24rpx;
+    text-align: center;
+    color: #999;
+    font-size: 28rpx;
+  }
+
+  .state-loading {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 28rpx;
+    min-height: 50vh;
+    padding: 96rpx 32rpx;
+    box-sizing: border-box;
+  }
+
+  .pulse-dots {
+    display: flex;
+    align-items: center;
+    gap: 16rpx;
+  }
+
+  .pulse-dot {
+    width: 16rpx;
+    height: 16rpx;
+    border-radius: 50%;
+    background: var(--brand-green, #22c55e);
+    animation: oa-pulse-bounce 1.2s ease-in-out infinite;
+  }
+
+  .pulse-dot:nth-child(2) {
+    animation-delay: 0.15s;
+    opacity: 0.75;
+  }
+
+  .pulse-dot:nth-child(3) {
+    animation-delay: 0.3s;
+    opacity: 0.55;
+  }
+
+  .state-loading__text {
+    font-size: 26rpx;
+    font-weight: 500;
+    color: var(--text-muted, #5f7a6d);
+    line-height: 1.4;
+  }
+
+  @keyframes oa-pulse-bounce {
+    0%,
+    80%,
+    100% {
+      transform: translateY(0);
+    }
+    40% {
+      transform: translateY(-12rpx);
+    }
+  }
+</style>