Просмотр исходного кода

feat(web-admin): add mp webview CRM contract list pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 3 недель назад
Родитель
Сommit
a9ee5d440e

+ 7 - 2
web-admin/src/utils/mp-crm-contract-card.ts

@@ -55,6 +55,9 @@ export function buildContractListQuery(input: {
 export function buildContractCard(row: {
   id: string | number
   contractNo?: string
+  /** 桌面/API 字段 */
+  contractName?: string
+  /** 兼容旧测试/别名 */
   contractTitle?: string
   customerName?: string
   amount?: number
@@ -62,16 +65,18 @@ export function buildContractCard(row: {
   barrelDeposit?: number
   status?: string
   signDate?: string
+  canApprove?: boolean
 }) {
   const total =
     (Number(row.amount) || 0) + (Number(row.freight) || 0) + (Number(row.barrelDeposit) || 0)
   return {
     id: String(row.id),
     contractNo: row.contractNo || '—',
-    contractTitle: row.contractTitle || '—',
+    contractTitle: row.contractName || row.contractTitle || '—',
     customerName: row.customerName || '—',
     amountText: formatContractMoney(total),
     status: row.status || '',
-    signDate: row.signDate || '—'
+    signDate: row.signDate || '—',
+    canApprove: !!row.canApprove
   }
 }

+ 813 - 0
web-admin/src/views/m/crm/contract/MpContractList.vue

@@ -0,0 +1,813 @@
+<template>
+  <MpWebviewShell @ready="onShellReady">
+    <div v-if="forbidden" class="mp-wv-empty">无权限</div>
+    <template v-else>
+      <header class="mp-wv-header">
+        <div class="mp-wv-header__row">
+          <button class="mp-wv-header__back" type="button" aria-label="返回工作台" @click="goBack">
+            ←
+          </button>
+          <h1 class="mp-wv-header__title">{{ headerTitle }}</h1>
+          <button class="mp-wv-header__action" type="button" @click="toggleSelectMode">
+            {{ selectMode ? '取消' : '多选' }}
+          </button>
+        </div>
+        <input
+          v-model="keyword"
+          class="mp-wv-search"
+          type="search"
+          placeholder="合同主题 / 客户 / 单号"
+          @search="reload"
+          @keyup.enter="reload"
+        />
+      </header>
+
+      <div class="mp-wv-filter-wrap">
+        <div class="mp-wv-filter-bar">
+          <button
+            class="mp-wv-filter-trig"
+            :class="{ open: openFilter === 'view' }"
+            type="button"
+            @click="toggleFilter"
+          >
+            <span class="mp-wv-filter-trig__val">{{ viewLabel }}</span>
+            <span class="mp-wv-filter-trig__arrow" aria-hidden="true">▼</span>
+          </button>
+        </div>
+        <div class="mp-wv-filter-panel" :class="{ open: openFilter === 'view' }">
+          <button
+            v-for="item in viewTabs"
+            :key="item.value"
+            class="mp-wv-filter-opt"
+            :class="{ active: view === item.value }"
+            type="button"
+            @click="pickView(item.value)"
+          >
+            <span>{{ item.label }}</span>
+            <span class="mp-wv-filter-opt__check" aria-hidden="true">✓</span>
+          </button>
+        </div>
+      </div>
+      <div
+        class="mp-wv-filter-mask"
+        :class="{ open: !!openFilter }"
+        @click="closeFilter"
+      />
+
+      <div
+        :class="{
+          'mp-wv-pad-speed': mode === 'manage' && !selectMode,
+          'mp-wv-pad-batch': selectMode
+        }"
+      >
+        <div
+          v-if="loading && !cards.length"
+          class="mp-plan-form-loading mp-plan-form-loading--page"
+          role="status"
+          aria-live="polite"
+          aria-busy="true"
+        >
+          <div class="mp-plan-form-loading__orb" aria-hidden="true">
+            <span class="mp-plan-form-loading__dot" />
+          </div>
+          <p class="mp-plan-form-loading__title">正在加载合同</p>
+          <p class="mp-plan-form-loading__desc">请稍候,正在获取合同列表…</p>
+          <div class="mp-plan-form-loading__skel" aria-hidden="true">
+            <div class="mp-wv-skel mp-wv-skel--block" />
+            <div class="mp-wv-skel mp-wv-skel--mid" />
+            <div class="mp-wv-skel mp-wv-skel--line" />
+            <div class="mp-wv-skel mp-wv-skel--short" />
+          </div>
+        </div>
+        <div v-else-if="!cards.length" class="mp-wv-empty">
+          <div class="mp-wv-empty__icon" aria-hidden="true">📄</div>
+          <div class="mp-wv-empty__title">暂无合同</div>
+          <div class="mp-wv-empty__desc">可调整筛选或搜索后再试</div>
+        </div>
+        <article
+          v-for="card in cards"
+          :key="card.id"
+          class="mp-wv-card mp-wv-card--selectable"
+          @click="onCardClick(card)"
+        >
+          <label
+            v-if="selectMode"
+            class="mp-wv-card__check"
+            @click.stop
+          >
+            <input
+              type="checkbox"
+              :checked="selectedIds.has(card.id)"
+              @change="toggleSelect(card.id)"
+            />
+          </label>
+          <div class="mp-wv-card__body">
+            <div class="mp-wv-card__title">{{ card.contractTitle }}</div>
+            <div class="mp-wv-card__meta">{{ card.contractNo }} · {{ card.customerName }}</div>
+            <div class="mp-wv-card__meta">{{ card.amountText }} · {{ card.signDate }}</div>
+            <div class="mp-wv-card__tags">
+              <el-tag :type="statusTagType(card.status)" size="small">
+                {{ statusLabel(card.status) }}
+              </el-tag>
+            </div>
+          </div>
+        </article>
+
+        <div v-if="hasMore" class="mp-wv-toolbar" style="padding: 0 0 12px">
+          <button
+            class="mp-wv-btn mp-wv-btn--block"
+            type="button"
+            :disabled="loading"
+            @click="loadMore"
+          >
+            <span v-if="loading" class="mp-wv-spinner mp-wv-spinner--sm" aria-hidden="true" />
+            <span>{{ loading ? '加载中…' : `加载更多(${contracts.length}/${total})` }}</span>
+          </button>
+        </div>
+      </div>
+
+      <div v-if="selectMode" class="mp-wv-batch-bar">
+        <span class="mp-wv-batch-bar__count">已选 {{ selectedIds.size }} 项</span>
+        <div class="mp-wv-batch-bar__actions">
+          <template v-if="mode === 'manage'">
+            <button
+              class="mp-wv-btn mp-wv-btn--primary"
+              type="button"
+              :disabled="!selectedIds.size || acting"
+              @click="openBatchSubmit"
+            >
+              提交审批
+            </button>
+            <button
+              class="mp-wv-btn"
+              type="button"
+              :disabled="!selectedIds.size || acting"
+              @click="batchInvalid"
+            >
+              作废
+            </button>
+          </template>
+          <template v-else>
+            <button
+              class="mp-wv-btn mp-wv-btn--primary"
+              type="button"
+              :disabled="!selectedIds.size || acting"
+              @click="openBatchApprove('approved')"
+            >
+              通过
+            </button>
+            <button
+              class="mp-wv-btn is-danger"
+              type="button"
+              :disabled="!selectedIds.size || acting"
+              @click="openBatchApprove('rejected')"
+            >
+              驳回
+            </button>
+          </template>
+        </div>
+      </div>
+
+      <template v-if="mode === 'manage' && !selectMode">
+        <div class="mp-wv-speed-mask" :class="{ open: speedOpen }" @click="speedOpen = false" />
+        <div class="mp-wv-speed-dial" :class="{ open: speedOpen }">
+          <div class="mp-wv-speed-menu">
+            <button class="mp-wv-speed-item primary" type="button" @click="goNew">新增合同</button>
+          </div>
+          <button class="mp-wv-speed-main" type="button" aria-label="快捷操作" @click="toggleSpeed">
+            +
+          </button>
+        </div>
+      </template>
+
+      <MpBottomSheet v-model="submitOpen" title="提交审批">
+        <div class="mp-quote-sheet-form">
+          <p v-if="batchSkipped > 0" class="mp-wv-sheet-tip">
+            将提交 {{ batchTargetIds.length }} 条草稿;已跳过 {{ batchSkipped }} 条非草稿
+          </p>
+          <label class="mp-quote-sheet-label">审批人(可多选,按选择顺序)</label>
+          <el-select
+            v-model="submitForm.approverIds"
+            multiple
+            filterable
+            placeholder="请选择审批人"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="user in userOptions"
+              :key="user.userId"
+              :label="formatApproverOptionLabel(user)"
+              :value="user.userId"
+            />
+          </el-select>
+          <label class="mp-quote-sheet-label">备注说明</label>
+          <el-input
+            v-model="submitForm.remark"
+            type="textarea"
+            :rows="3"
+            maxlength="500"
+            show-word-limit
+            placeholder="可选"
+          />
+          <button
+            class="mp-wv-btn mp-wv-btn--primary mp-wv-btn--block"
+            type="button"
+            :disabled="acting"
+            @click="confirmBatchSubmit"
+          >
+            确认提交
+          </button>
+        </div>
+      </MpBottomSheet>
+
+      <MpBottomSheet v-model="approveOpen" :title="approveSheetTitle">
+        <div class="mp-quote-sheet-form">
+          <p v-if="batchSkipped > 0" class="mp-wv-sheet-tip">
+            将处理 {{ batchTargetIds.length }} 条;已跳过 {{ batchSkipped }} 条不可审批
+          </p>
+          <label class="mp-quote-sheet-label">
+            审批意见{{ approveResult === 'rejected' ? '(必填)' : '(可选)' }}
+          </label>
+          <el-input
+            v-model="approveOpinion"
+            type="textarea"
+            :rows="4"
+            maxlength="500"
+            show-word-limit
+            :placeholder="approveResult === 'rejected' ? '请填写驳回理由' : '请输入审批意见'"
+          />
+          <button
+            class="mp-wv-btn mp-wv-btn--primary mp-wv-btn--block"
+            type="button"
+            :disabled="acting"
+            @click="confirmBatchApprove"
+          >
+            确认{{ approveResult === 'rejected' ? '驳回' : '通过' }}
+          </button>
+        </div>
+      </MpBottomSheet>
+    </template>
+  </MpWebviewShell>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref } from 'vue'
+  import { useRouter } from 'vue-router'
+  import { ElMessage, ElMessageBox } from 'element-plus'
+  import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
+  import MpBottomSheet from '@/components/mp/MpBottomSheet.vue'
+  import {
+    listContract,
+    batchSubmitContract,
+    approveContract,
+    rejectContract,
+    invalidContract,
+    getContractApproverSetting
+  } from '@/api/crm/contract'
+  import { fetchUserOptions } from '@/api/admin'
+  import {
+    MP_CONTRACT_APPROVE_TABS,
+    MP_CONTRACT_MANAGE_TABS,
+    buildContractCard,
+    buildContractListQuery,
+    type MpContractMode,
+    type MpContractView
+  } from '@/utils/mp-crm-contract-card'
+  import { useUserStore } from '@/store/modules/user'
+  import { useDictStore } from '@/store/modules/dict'
+  import { goToMiniWorkbench } from '@/utils/mp-webview'
+  import { HttpError } from '@/utils/http/error'
+  import { ApiStatus } from '@/utils/http/status'
+  import '@/styles/mp-webview.scss'
+
+  type ContractRow = {
+    id: string | number
+    contractNo?: string
+    contractName?: string
+    customerName?: string
+    amount?: number
+    freight?: number
+    barrelDeposit?: number
+    status?: string
+    signDate?: string
+    canApprove?: boolean
+  }
+
+  type ApproverOption = {
+    userId: string
+    nickName: string
+    userName: string
+  }
+
+  const props = defineProps<{
+    mode: MpContractMode
+  }>()
+
+  const router = useRouter()
+  const userStore = useUserStore()
+  const dictStore = useDictStore()
+
+  const keyword = ref('')
+  const view = ref<MpContractView>(props.mode === 'approve' ? 'submitted' : 'all')
+  const openFilter = ref<'' | 'view'>('')
+  const speedOpen = ref(false)
+  const selectMode = ref(false)
+  const selectedIds = ref<Set<string>>(new Set())
+  const loading = ref(false)
+  const acting = ref(false)
+  const forbidden = ref(false)
+  const pageNum = ref(1)
+  const pageSize = 20
+  const total = ref(0)
+  const contracts = ref<ContractRow[]>([])
+
+  const submitOpen = ref(false)
+  const submitForm = ref<{ approverIds: string[]; remark: string }>({
+    approverIds: [],
+    remark: ''
+  })
+  const userOptions = ref<ApproverOption[]>([])
+  const approveOpen = ref(false)
+  const approveResult = ref<'approved' | 'rejected'>('approved')
+  const approveOpinion = ref('')
+  const batchTargetIds = ref<(string | number)[]>([])
+  const batchSkipped = ref(0)
+
+  const resolveCurrentUserId = (): string => {
+    const info = userStore.info || ({} as Api.Auth.UserInfo)
+    const fromInfo = String(info.id || info.userId || '')
+    if (fromInfo) return fromInfo
+    const token = userStore.accessToken
+    if (!token || token.split('.').length < 2) return ''
+    try {
+      const payload = token.split('.')[1]
+      const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
+      const pad = '='.repeat((4 - (b64.length % 4)) % 4)
+      const obj = JSON.parse(atob(b64 + pad))
+      return String(obj?.sub || '')
+    } catch {
+      return ''
+    }
+  }
+
+  const headerTitle = computed(() =>
+    props.mode === 'approve' ? '合同审批' : '合同管理'
+  )
+
+  const viewTabs = computed(() =>
+    props.mode === 'approve' ? MP_CONTRACT_APPROVE_TABS : MP_CONTRACT_MANAGE_TABS
+  )
+
+  const viewLabel = computed(
+    () => viewTabs.value.find((item) => item.value === view.value)?.label || '全部'
+  )
+
+  const cards = computed(() => contracts.value.map((row) => buildContractCard(row)))
+
+  const hasMore = computed(() => contracts.value.length < total.value)
+
+  const approveSheetTitle = computed(() =>
+    approveResult.value === 'rejected' ? '批量驳回' : '批量通过'
+  )
+
+  function statusLabel(status?: string) {
+    const raw = status || ''
+    return dictStore.getLabel('crm_contract_status', raw) || raw || '—'
+  }
+
+  function statusTagType(status?: string) {
+    return dictStore.getTagType('crm_contract_status', status || '') as
+      | 'success'
+      | 'warning'
+      | 'info'
+      | 'primary'
+      | 'danger'
+  }
+
+  function formatApproverOptionLabel(user: ApproverOption) {
+    const nick = (user.nickName || '').trim()
+    const account = (user.userName || '').trim()
+    if (nick && account) return `${nick}(${account})`
+    return nick || account
+  }
+
+  function clearSelection() {
+    selectedIds.value = new Set()
+  }
+
+  async function fetchPage(reset: boolean) {
+    if (reset) {
+      pageNum.value = 1
+      contracts.value = []
+      clearSelection()
+    }
+    loading.value = true
+    try {
+      const res = await listContract(
+        buildContractListQuery({
+          mode: props.mode,
+          view: view.value,
+          pageNum: pageNum.value,
+          pageSize,
+          keyword: keyword.value,
+          currentUserId: resolveCurrentUserId()
+        }) as any
+      )
+      const list = ((res as any)?.rows || []) as ContractRow[]
+      total.value = (res as any)?.total ?? 0
+      contracts.value = reset ? list : contracts.value.concat(list)
+      forbidden.value = false
+    } catch (error) {
+      if (error instanceof HttpError && error.code === ApiStatus.forbidden) {
+        forbidden.value = true
+        contracts.value = []
+        return
+      }
+      throw error
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function reload() {
+    void fetchPage(true)
+  }
+
+  function closeFilter() {
+    openFilter.value = ''
+  }
+
+  function toggleFilter() {
+    speedOpen.value = false
+    openFilter.value = openFilter.value === 'view' ? '' : 'view'
+  }
+
+  function pickView(value: MpContractView) {
+    view.value = value
+    closeFilter()
+    reload()
+  }
+
+  function loadMore() {
+    pageNum.value += 1
+    void fetchPage(false)
+  }
+
+  function toggleSpeed() {
+    closeFilter()
+    speedOpen.value = !speedOpen.value
+  }
+
+  function toggleSelectMode() {
+    selectMode.value = !selectMode.value
+    speedOpen.value = false
+    closeFilter()
+    if (!selectMode.value) clearSelection()
+  }
+
+  function toggleSelect(id: string) {
+    const next = new Set(selectedIds.value)
+    if (next.has(id)) next.delete(id)
+    else next.add(id)
+    selectedIds.value = next
+  }
+
+  function onCardClick(card: { id: string }) {
+    if (selectMode.value) {
+      toggleSelect(card.id)
+      return
+    }
+    goDetail(card.id)
+  }
+
+  function goBack() {
+    goToMiniWorkbench()
+  }
+
+  function goNew() {
+    speedOpen.value = false
+    void router.push('/m/crm/contract/new')
+  }
+
+  function goDetail(id: string) {
+    void router.push({ path: `/m/crm/contract/${id}`, query: { from: props.mode } })
+  }
+
+  function selectedRows(): ContractRow[] {
+    return contracts.value.filter((row) => selectedIds.value.has(String(row.id)))
+  }
+
+  async function loadUserOptions() {
+    try {
+      const res = await fetchUserOptions()
+      const options = (res as any)?.data ?? res ?? []
+      userOptions.value = (Array.isArray(options) ? options : [])
+        .map((item: any) => ({
+          userId: String(item.value ?? item.userId ?? item.id ?? ''),
+          nickName: item.nickName ?? item.label ?? item.userName ?? '',
+          userName: item.userNo ?? item.user_no ?? item.userName ?? ''
+        }))
+        .filter((u: ApproverOption) => u.userId)
+    } catch {
+      userOptions.value = []
+    }
+  }
+
+  async function resolvePrefillApproverIds() {
+    try {
+      const { data } = await getContractApproverSetting()
+      if (!data || String(data.status) !== '0') return [] as string[]
+      const valid = new Set(userOptions.value.map((u) => String(u.userId)))
+      return (data.approverIds || []).map(String).filter((id) => valid.has(id))
+    } catch {
+      return [] as string[]
+    }
+  }
+
+  async function openBatchSubmit() {
+    const rows = selectedRows()
+    if (!rows.length) {
+      ElMessage.warning('请先选择合同')
+      return
+    }
+    const drafts = rows.filter((r) => r.status === 'draft')
+    const skipped = rows.length - drafts.length
+    if (!drafts.length) {
+      ElMessage.warning('选中的合同中没有草稿状态的合同')
+      return
+    }
+    if (skipped > 0) {
+      ElMessage.warning(`已跳过 ${skipped} 条非草稿合同,仅处理草稿`)
+    }
+    batchTargetIds.value = drafts.map((r) => r.id)
+    batchSkipped.value = skipped
+    acting.value = true
+    try {
+      await loadUserOptions()
+      const prefill = await resolvePrefillApproverIds()
+      submitForm.value = { approverIds: prefill, remark: '' }
+      submitOpen.value = true
+    } finally {
+      acting.value = false
+    }
+  }
+
+  async function confirmBatchSubmit() {
+    if (!submitForm.value.approverIds.length) {
+      ElMessage.warning('请选择审批人')
+      return
+    }
+    if (!batchTargetIds.value.length) return
+    acting.value = true
+    try {
+      const count = await batchSubmitContract(
+        batchTargetIds.value,
+        submitForm.value.approverIds,
+        submitForm.value.remark || undefined
+      )
+      const n = typeof count === 'number' ? count : batchTargetIds.value.length
+      ElMessage.success(
+        batchSkipped.value > 0
+          ? `成功提交 ${n} 条,跳过 ${batchSkipped.value} 条`
+          : `成功提交 ${n} 条`
+      )
+      submitOpen.value = false
+      selectMode.value = false
+      clearSelection()
+      reload()
+    } catch (error) {
+      ElMessage.error(error instanceof Error ? error.message : '提交失败')
+    } finally {
+      acting.value = false
+    }
+  }
+
+  function openBatchApprove(result: 'approved' | 'rejected') {
+    const rows = selectedRows()
+    if (!rows.length) {
+      ElMessage.warning('请先选择合同')
+      return
+    }
+    const valid = rows.filter((r) => r.canApprove)
+    const skipped = rows.length - valid.length
+    if (!valid.length) {
+      ElMessage.warning(
+        rows.length === 1
+          ? '您没有该合同的审批权限,或已审批过该合同'
+          : '所选合同均不可审批(无权、已审或不在待审批状态)'
+      )
+      return
+    }
+    if (skipped > 0) {
+      ElMessage.warning(`已跳过 ${skipped} 条不可审批的合同`)
+    }
+    batchTargetIds.value = valid.map((r) => r.id)
+    batchSkipped.value = skipped
+    approveResult.value = result
+    approveOpinion.value = result === 'approved' ? '审批通过' : ''
+    approveOpen.value = true
+  }
+
+  async function confirmBatchApprove() {
+    if (approveResult.value === 'rejected' && !String(approveOpinion.value || '').trim()) {
+      ElMessage.warning('请填写驳回理由')
+      return
+    }
+    if (!batchTargetIds.value.length) return
+    acting.value = true
+    try {
+      const opinion = String(approveOpinion.value || '').trim()
+      const results = await Promise.allSettled(
+        batchTargetIds.value.map((id) =>
+          approveResult.value === 'rejected'
+            ? rejectContract(id, opinion)
+            : approveContract(id, opinion || '审批通过')
+        )
+      )
+      const successCount = results.filter((item) => item.status === 'fulfilled').length
+      const failedCount = results.length - successCount
+      const actionLabel = approveResult.value === 'rejected' ? '驳回' : '通过'
+      const skipText = batchSkipped.value > 0 ? `,跳过 ${batchSkipped.value} 条` : ''
+      if (successCount === 0) {
+        ElMessage.error(`${actionLabel}失败${skipText}`)
+        return
+      }
+      if (failedCount > 0) {
+        ElMessage.warning(`成功${actionLabel} ${successCount} 条,失败 ${failedCount} 条${skipText}`)
+      } else {
+        ElMessage.success(`成功${actionLabel} ${successCount} 条${skipText}`)
+      }
+      approveOpen.value = false
+      selectMode.value = false
+      clearSelection()
+      reload()
+    } catch (error) {
+      ElMessage.error(error instanceof Error ? error.message : '审批失败')
+    } finally {
+      acting.value = false
+    }
+  }
+
+  async function batchInvalid() {
+    const rows = selectedRows().filter((r) => r.status && r.status !== 'invalid')
+    const skipped = selectedRows().length - rows.length
+    if (!rows.length) {
+      ElMessage.warning('选中的合同均不可作废')
+      return
+    }
+    let reason = ''
+    try {
+      const { value } = await ElMessageBox.prompt('请输入作废原因', '批量作废', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        inputPlaceholder: '作废原因',
+        inputValidator: (v) => {
+          if (!String(v || '').trim()) return '请填写作废原因'
+          return true
+        }
+      })
+      reason = String(value || '').trim()
+    } catch {
+      return
+    }
+    acting.value = true
+    try {
+      const results = await Promise.allSettled(
+        rows.map((r) => invalidContract({ id: r.id, invalidReason: reason }))
+      )
+      const successCount = results.filter((item) => item.status === 'fulfilled').length
+      const failedCount = results.length - successCount
+      const skipText = skipped > 0 ? `,跳过 ${skipped} 条` : ''
+      if (successCount === 0) {
+        ElMessage.error(`作废失败${skipText}`)
+        return
+      }
+      if (failedCount > 0) {
+        ElMessage.warning(`成功作废 ${successCount} 条,失败 ${failedCount} 条${skipText}`)
+      } else {
+        ElMessage.success(`成功作废 ${successCount} 条${skipText}`)
+      }
+      selectMode.value = false
+      clearSelection()
+      reload()
+    } catch (error) {
+      ElMessage.error(error instanceof Error ? error.message : '作废失败')
+    } finally {
+      acting.value = false
+    }
+  }
+
+  function onShellReady() {
+    view.value = props.mode === 'approve' ? 'submitted' : 'all'
+    keyword.value = ''
+    speedOpen.value = false
+    selectMode.value = false
+    clearSelection()
+    closeFilter()
+    reload()
+  }
+</script>
+
+<style scoped lang="scss">
+  .mp-wv-header__action {
+    margin-left: auto;
+    padding: 6px 10px;
+    font-size: 13px;
+    color: #087f57;
+    background: transparent;
+    border: none;
+  }
+
+  .mp-wv-card--selectable {
+    display: flex;
+    gap: 10px;
+    align-items: flex-start;
+  }
+
+  .mp-wv-card__check {
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    padding-top: 2px;
+  }
+
+  .mp-wv-card__check input {
+    width: 18px;
+    height: 18px;
+  }
+
+  .mp-wv-card__body {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .mp-wv-card__title {
+    font-size: 15px;
+    font-weight: 700;
+    color: #087f57;
+  }
+
+  .mp-wv-card__tags {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin-top: 8px;
+  }
+
+  .mp-wv-pad-batch {
+    padding-bottom: 88px;
+  }
+
+  .mp-wv-batch-bar {
+    position: fixed;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    z-index: 40;
+    display: flex;
+    gap: 10px;
+    align-items: center;
+    justify-content: space-between;
+    padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
+    background: #fff;
+    border-top: 1px solid #e2e8f0;
+    box-shadow: 0 -4px 16px rgb(15 23 42 / 6%);
+  }
+
+  .mp-wv-batch-bar__count {
+    font-size: 13px;
+    color: #475569;
+    white-space: nowrap;
+  }
+
+  .mp-wv-batch-bar__actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    justify-content: flex-end;
+  }
+
+  .mp-wv-sheet-tip {
+    margin: 0 0 10px;
+    font-size: 12px;
+    line-height: 1.4;
+    color: #b45309;
+  }
+
+  .mp-quote-sheet-form {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+  }
+
+  .mp-quote-sheet-label {
+    font-size: 13px;
+    font-weight: 600;
+    color: #334155;
+  }
+
+  .is-danger {
+    color: #dc2626 !important;
+    border-color: #fecaca !important;
+  }
+</style>

+ 2 - 13
web-admin/src/views/m/crm/contract/approval.vue

@@ -1,17 +1,6 @@
 <template>
-  <MpWebviewShell>
-    <header class="mp-wv-header">
-      <div class="mp-wv-header__row">
-        <h1 class="mp-wv-header__title">{{ title }}</h1>
-      </div>
-    </header>
-  </MpWebviewShell>
+  <MpContractList mode="approve" />
 </template>
 <script setup lang="ts">
-  import { computed } from 'vue'
-  import { useRoute } from 'vue-router'
-  import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
-  import '@/styles/mp-webview.scss'
-  const route = useRoute()
-  const title = computed(() => String(route.meta.title || ''))
+  import MpContractList from './MpContractList.vue'
 </script>

+ 2 - 13
web-admin/src/views/m/crm/contract/index.vue

@@ -1,17 +1,6 @@
 <template>
-  <MpWebviewShell>
-    <header class="mp-wv-header">
-      <div class="mp-wv-header__row">
-        <h1 class="mp-wv-header__title">{{ title }}</h1>
-      </div>
-    </header>
-  </MpWebviewShell>
+  <MpContractList mode="manage" />
 </template>
 <script setup lang="ts">
-  import { computed } from 'vue'
-  import { useRoute } from 'vue-router'
-  import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
-  import '@/styles/mp-webview.scss'
-  const route = useRoute()
-  const title = computed(() => String(route.meta.title || ''))
+  import MpContractList from './MpContractList.vue'
 </script>