Explorar el Código

feat(mobile-h5): add styles, mocks, components, and demo pages

Land Token-based SCSS modules, menu/biz mocks, reusable shell
components, and home plus list/detail/form/comps template pages
aligned with the Vant Weapp UI demo.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hace 2 meses
padre
commit
8793fa07cc

+ 75 - 0
mobile-h5/components/AttachList.vue

@@ -0,0 +1,75 @@
+<template>
+  <view class="d-attach-list">
+    <view class="d-attach-upload" @click="$emit('upload')">
+      <view class="up-icon"><van-icon name="plus" /></view>
+      <view class="up-text">
+        <view class="up-title">添加附件</view>
+        <view class="up-sub">支持 PDF / 图片 / Office,可多选上传</view>
+      </view>
+      <van-icon name="arrow" color="#0a9f6d" />
+    </view>
+
+    <view
+      v-for="a in files"
+      :key="a.name"
+      class="d-attach-card"
+      :class="fileTypeClass(a.name)"
+    >
+      <view class="file-icon">
+        <van-icon :name="fileTypeIcon(a.name)" />
+        <text class="ext">{{ fileExt(a.name) }}</text>
+      </view>
+      <view class="file-body">
+        <view class="file-name">{{ a.name }}</view>
+        <view class="file-meta">
+          <text>{{ a.size || '—' }}</text>
+          <text>{{ a.time || '—' }}</text>
+        </view>
+      </view>
+      <view class="file-ops">
+        <view class="op-btn btn-preview" @click="$emit('preview', a)">
+          <van-icon name="eye-o" />
+        </view>
+        <view class="op-btn btn-download" @click="$emit('download', a)">
+          <view class="icon-download">
+            <view style="width:28rpx;height:28rpx;position:relative">
+              <van-icon name="down" size="14px" />
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'AttachList',
+    props: {
+      files: { type: Array, default: () => [] }
+    },
+    emits: ['upload', 'preview', 'download'],
+    methods: {
+      fileExt(name) {
+        const i = (name || '').lastIndexOf('.')
+        return i >= 0 ? name.slice(i + 1).slice(0, 4) : 'FILE'
+      },
+      fileTypeClass(name) {
+        const ext = this.fileExt(name).toLowerCase()
+        if (ext === 'pdf') return 'ft-pdf'
+        if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) return 'ft-img'
+        if (['xls', 'xlsx', 'csv'].includes(ext)) return 'ft-xls'
+        if (['doc', 'docx'].includes(ext)) return 'ft-doc'
+        return 'ft-other'
+      },
+      fileTypeIcon(name) {
+        const cls = this.fileTypeClass(name)
+        if (cls === 'ft-pdf') return 'description'
+        if (cls === 'ft-img') return 'photo-o'
+        if (cls === 'ft-xls') return 'bar-chart-o'
+        if (cls === 'ft-doc') return 'records'
+        return 'bag-o'
+      }
+    }
+  }
+</script>

+ 61 - 0
mobile-h5/components/BizListCard.vue

@@ -0,0 +1,61 @@
+<template>
+  <view class="biz-card" :class="'status-' + item.tagType">
+    <view class="card-inner">
+      <view class="row-top">
+        <view class="type-icon">
+          <van-icon :name="item.icon" />
+        </view>
+        <view class="title-wrap">
+          <view class="type-chip">{{ item.typeLabel }}</view>
+          <view class="title">{{ item.title }}</view>
+        </view>
+        <text class="status-pill">{{ item.status }}</text>
+      </view>
+
+      <view class="body-row">
+        <view class="desc-box">{{ item.desc }}</view>
+        <view class="side-metric">
+          <template v-if="item.amount">
+            <text class="amt-label">金额</text>
+            <text class="amt">¥{{ item.amount }}</text>
+          </template>
+          <template v-else>
+            <text class="amt-label">进度</text>
+            <text class="amt" style="font-size: 26rpx">{{ item.progress || '—' }}</text>
+          </template>
+          <text class="time">{{ item.time }}</text>
+        </view>
+      </view>
+
+      <view class="meta-row">
+        <text class="mi code-pill">{{ item.code }}</text>
+        <text class="mi">{{ item.owner }}</text>
+      </view>
+
+      <view class="footer">
+        <view class="left-hint">
+          <view class="dot" />
+          {{ item.hint || '点击操作处理' }}
+        </view>
+        <view class="ops">
+          <van-button size="small" plain hairline type="default" @click.stop="$emit('view', item)">
+            查看
+          </van-button>
+          <van-button size="small" type="primary" color="#0a9f6d" @click.stop="$emit('action', item)">
+            处理
+          </van-button>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'BizListCard',
+    props: {
+      item: { type: Object, required: true }
+    },
+    emits: ['view', 'action']
+  }
+</script>

+ 44 - 0
mobile-h5/components/FlowTimeline.vue

@@ -0,0 +1,44 @@
+<template>
+  <view class="flow-list">
+    <view
+      v-for="(log, idx) in logs"
+      :key="idx"
+      class="flow-item"
+      :class="log.tone"
+    >
+      <view class="flow-rail">
+        <view class="flow-dot">
+          <van-icon :name="log.icon || 'checked'" />
+        </view>
+      </view>
+      <view class="flow-card">
+        <view class="flow-top">
+          <view class="flow-title">{{ log.title }}</view>
+          <text class="flow-badge">{{
+            log.badge || (log.tone === 'warn' ? '进行中' : '已完成')
+          }}</text>
+        </view>
+        <view class="flow-desc">{{ log.desc }}</view>
+        <view class="flow-meta">
+          <view v-if="log.actor" class="chip">
+            <van-icon name="manager-o" />
+            {{ log.actor }}
+          </view>
+          <view class="chip">
+            <van-icon name="clock-o" />
+            {{ log.time }}
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'FlowTimeline',
+    props: {
+      logs: { type: Array, default: () => [] }
+    }
+  }
+</script>

+ 25 - 0
mobile-h5/components/MenuGrid.vue

@@ -0,0 +1,25 @@
+<template>
+  <view class="menu-grid">
+    <view
+      v-for="m in menus"
+      :key="m.id"
+      class="menu-card"
+      @click="$emit('select', m)"
+    >
+      <view class="menu-icon" :class="m.tone || ''">
+        <van-icon :name="m.icon" />
+      </view>
+      <text class="menu-title">{{ m.title }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'MenuGrid',
+    props: {
+      menus: { type: Array, default: () => [] }
+    },
+    emits: ['select']
+  }
+</script>

+ 61 - 0
mobile-h5/components/TodoPanel.vue

@@ -0,0 +1,61 @@
+<template>
+  <view class="todo-panel panel" style="padding: 0; overflow: hidden">
+    <view class="panel-hd" style="padding: 28rpx 24rpx 0">
+      <text class="panel-title">待办摘要</text>
+      <view class="hint" @click="$emit('more')">查看全部 ›</view>
+    </view>
+    <view style="padding: 24rpx">
+      <view class="todo-stats">
+        <view class="todo-stat tone-warn" @click="$emit('stat-click', 'approve')">
+          <view class="stat-icon"><van-icon name="passed" /></view>
+          <text class="num">{{ stats.approve }}</text>
+          <text class="label">待审批</text>
+        </view>
+        <view class="todo-stat tone-pay" @click="$emit('stat-click', 'pay')">
+          <view class="stat-icon"><van-icon name="balance-list-o" /></view>
+          <text class="num">{{ stats.pay }}</text>
+          <text class="label">待确认缴费</text>
+        </view>
+        <view class="todo-stat tone-danger" @click="$emit('stat-click', 'alert')">
+          <view class="stat-icon"><van-icon name="warning-o" /></view>
+          <text class="num">{{ stats.alert }}</text>
+          <text class="label">异常提醒</text>
+        </view>
+      </view>
+      <view class="todo-list">
+        <view
+          v-for="t in items"
+          :key="t.id"
+          class="todo-item"
+          @click="$emit('item-click', t)"
+        >
+          <view class="ti-icon" :class="t.iconClass">
+            <van-icon :name="t.icon" />
+          </view>
+          <view class="ti-body">
+            <view class="ti-title">{{ t.title }}</view>
+            <view class="ti-desc">{{ t.desc }}</view>
+          </view>
+          <view class="ti-right">
+            <van-tag :type="t.tagType" plain>{{ t.tag }}</van-tag>
+            <text class="ti-time">{{ t.time }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  export default {
+    name: 'TodoPanel',
+    props: {
+      stats: {
+        type: Object,
+        default: () => ({ approve: 0, pay: 0, alert: 0 })
+      },
+      items: { type: Array, default: () => [] }
+    },
+    emits: ['stat-click', 'item-click', 'more']
+  }
+</script>

+ 113 - 0
mobile-h5/pages/demo/comps.vue

@@ -0,0 +1,113 @@
+<template>
+  <view class="page-shell comps-page">
+    <view class="list-hero">
+      <van-nav-bar title="组件风格" left-arrow :border="false" @click-left="goHome" />
+      <view class="list-hero-copy">
+        <view class="eyebrow">DESIGN SYSTEM</view>
+        <view class="title">组件合集</view>
+        <view class="subline">按钮 · 状态 · 反馈 · 附件等基础样式</view>
+      </view>
+    </view>
+
+    <view class="home-body">
+      <view class="panel">
+        <view class="panel-hd"><text class="panel-title">按钮 Button</text></view>
+        <view class="comp-row">
+          <van-button type="primary" size="small" color="#0a9f6d" round>主按钮</van-button>
+          <van-button type="primary" size="small" plain hairline color="#0a9f6d" round>描边</van-button>
+          <van-button size="small" plain hairline round>次要</van-button>
+          <van-button type="danger" size="small" plain hairline round>危险</van-button>
+        </view>
+        <van-button
+          type="primary"
+          color="#0a9f6d"
+          block
+          round
+          custom-style="margin-top: 16rpx; height: 88rpx;"
+        >
+          通栏主按钮(44px)
+        </van-button>
+      </view>
+
+      <view class="panel">
+        <view class="panel-hd"><text class="panel-title">状态 Tag</text></view>
+        <view class="comp-row">
+          <van-tag type="warning" plain round>待处理</van-tag>
+          <van-tag type="primary" color="#0a9f6d" round>进行中</van-tag>
+          <van-tag type="success" plain round>已完成</van-tag>
+          <van-tag type="danger" plain round>异常</van-tag>
+        </view>
+      </view>
+
+      <view class="panel">
+        <view class="panel-hd"><text class="panel-title">通知 / 空态 / 加载</text></view>
+        <van-notice-bar
+          left-icon="volume-o"
+          text="移动端菜单在「菜单管理 → 移动端菜单」下配置,按角色授权后展示。"
+          background="#e9f9f2"
+          color="#087f57"
+        />
+        <view style="min-height: 360rpx; margin-top: 24rpx">
+          <van-empty description="暂无相关数据" />
+        </view>
+        <view style="display: flex; justify-content: center; padding: 32rpx 0">
+          <van-loading size="24px" color="#0a9f6d">加载中</van-loading>
+        </view>
+      </view>
+
+      <view class="panel">
+        <view class="panel-hd"><text class="panel-title">反馈</text></view>
+        <view class="comp-row">
+          <van-button size="small" plain hairline color="#0a9f6d" @click="toast('操作成功')">
+            Toast
+          </van-button>
+          <van-button size="small" plain hairline color="#0a9f6d" @click="showDialog">
+            Dialog
+          </van-button>
+        </view>
+      </view>
+
+      <view class="panel">
+        <view class="panel-hd"><text class="panel-title">附件卡片</text></view>
+        <AttachList
+          :files="[{ name: '质检报告.docx', size: '1.2 MB', time: '08-10 10:20' }]"
+          @upload="toast('上传')"
+          @preview="toast('预览')"
+          @download="toast('下载')"
+        />
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+  import AttachList from '../../components/AttachList.vue'
+
+  export default {
+    components: { AttachList },
+    methods: {
+      goHome() {
+        uni.reLaunch({ url: '/pages/index/index' })
+      },
+      toast(msg) {
+        uni.showToast({ title: msg, icon: 'none' })
+      },
+      showDialog() {
+        uni.showModal({
+          title: '确认提交',
+          content: '提交后将进入审批流程,是否继续?',
+          confirmColor: '#0a9f6d'
+        })
+      }
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  .comp-row {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 16rpx;
+    align-items: center;
+  }
+</style>

+ 139 - 0
mobile-h5/pages/demo/detail.vue

@@ -0,0 +1,139 @@
+<template>
+  <view class="detail-page" v-if="detail">
+    <view class="detail-hero" :class="'status-' + detail.tagType">
+      <van-nav-bar
+        title="业务详情"
+        left-arrow
+        :border="false"
+        @click-left="goBack"
+      />
+      <view class="detail-hero-body">
+        <view class="type-line">
+          <text class="type-chip">{{ detail.typeLabel }}</text>
+          <text class="status-chip">{{ detail.status }}</text>
+        </view>
+        <view class="hero-title">{{ detail.title }}</view>
+        <view class="code">{{ detail.code }}</view>
+      </view>
+    </view>
+
+    <view class="detail-metric-dock">
+      <view class="detail-metric">
+        <text class="lab">{{ detail.amount ? '金额' : '进度' }}</text>
+        <text class="val" :class="{ money: !!detail.amount }">
+          {{ detail.amount ? '¥' + detail.amount : detail.progress || '—' }}
+        </text>
+      </view>
+      <view class="detail-metric">
+        <text class="lab">负责人</text>
+        <text class="val">{{ detail.owner }}</text>
+      </view>
+      <view class="detail-metric">
+        <text class="lab">更新时间</text>
+        <text class="val">{{ detail.time }}</text>
+      </view>
+    </view>
+
+    <view class="detail-body">
+      <view class="detail-section">
+        <view class="sec-hd"><text class="sec-title">基础信息</text></view>
+        <view class="kv-list">
+          <view class="kv-row"><text class="k">业务类型</text><text class="v">{{ detail.typeLabel }}</text></view>
+          <view class="kv-row"><text class="k">所属部门</text><text class="v">{{ detail.dept }}</text></view>
+          <view class="kv-row"><text class="k">客户/对象</text><text class="v">{{ detail.customer }}</text></view>
+          <view class="kv-row"><text class="k">创建时间</text><text class="v">{{ detail.createdAt }}</text></view>
+        </view>
+        <view class="detail-note">{{ detail.desc }}</view>
+      </view>
+
+      <view class="detail-section">
+        <view class="sec-hd"><text class="sec-title">业务进度</text></view>
+        <van-steps :active="detail.stepActive" active-color="#0a9f6d">
+          <van-step v-for="s in detail.steps" :key="s">{{ s }}</van-step>
+        </van-steps>
+      </view>
+
+      <view class="detail-section" v-if="detail.payments && detail.payments.length">
+        <view class="sec-hd">
+          <text class="sec-title">缴费明细</text>
+          <text class="sec-more">全部</text>
+        </view>
+        <view v-for="p in detail.payments" :key="p.id" class="pay-row">
+          <view>
+            <view class="name">{{ p.name }}</view>
+            <view class="sub">{{ p.sub }}</view>
+          </view>
+          <view>
+            <view class="amt">¥{{ p.amount }}</view>
+            <van-tag :type="p.tagType" plain>{{ p.status }}</van-tag>
+          </view>
+        </view>
+      </view>
+
+      <view class="detail-section">
+        <view class="sec-hd">
+          <text class="sec-title">附件</text>
+          <text class="hint">{{ (detail.attachments || []).length }} 个文件</text>
+        </view>
+        <AttachList
+          :files="detail.attachments || []"
+          @upload="toast('上传附件')"
+          @preview="onPreview"
+          @download="onDownload"
+        />
+      </view>
+
+      <view class="detail-section">
+        <view class="sec-hd">
+          <text class="sec-title">流转记录</text>
+          <text class="hint">{{ (detail.logs || []).length }} 步</text>
+        </view>
+        <FlowTimeline :logs="detail.logs || []" />
+      </view>
+    </view>
+
+    <view class="detail-footer">
+      <van-button class="btn-flex" plain hairline type="default" @click="goBack">返回</van-button>
+      <van-button class="btn-flex" type="primary" color="#0a9f6d" round @click="goEdit">
+        编辑处理
+      </van-button>
+    </view>
+  </view>
+</template>
+
+<script>
+  import AttachList from '../../components/AttachList.vue'
+  import FlowTimeline from '../../components/FlowTimeline.vue'
+  import { getById } from '../../utils/biz-mock.js'
+
+  export default {
+    components: { AttachList, FlowTimeline },
+    data() {
+      return { detail: null }
+    },
+    onLoad(query) {
+      this.detail = getById(query.id)
+    },
+    methods: {
+      goBack() {
+        uni.navigateBack({
+          fail: () => uni.reLaunch({ url: '/pages/demo/list' })
+        })
+      },
+      goEdit() {
+        uni.navigateTo({
+          url: `/pages/demo/form?mode=edit&id=${this.detail.id}`
+        })
+      },
+      toast(msg) {
+        uni.showToast({ title: msg, icon: 'none' })
+      },
+      onPreview(a) {
+        this.toast('预览 ' + a.name)
+      },
+      onDownload(a) {
+        this.toast('下载 ' + a.name)
+      }
+    }
+  }
+</script>

+ 164 - 0
mobile-h5/pages/demo/form.vue

@@ -0,0 +1,164 @@
+<template>
+  <view class="form-page">
+    <view class="form-nav">
+      <van-nav-bar
+        :title="mode === 'create' ? '新增业务' : '编辑业务'"
+        left-arrow
+        :border="false"
+        @click-left="goBack"
+      />
+    </view>
+
+    <view class="form-section-title">基础信息</view>
+    <van-cell-group inset>
+      <van-field
+        :value="form.name"
+        label="名称"
+        placeholder="请输入名称"
+        required
+        @change="(e) => (form.name = e.detail)"
+      />
+      <van-field
+        :value="form.code"
+        label="业务单号"
+        placeholder="系统自动生成或手工录入"
+        @change="(e) => (form.code = e.detail)"
+      />
+      <van-field
+        :value="form.typeLabel"
+        label="类型"
+        placeholder="请选择类型"
+        is-link
+        readonly
+        @click-input="showType = true"
+      />
+      <van-cell title="加急">
+        <van-switch
+          slot="right-icon"
+          :checked="form.urgent"
+          size="20px"
+          active-color="#0a9f6d"
+          @change="(e) => (form.urgent = e.detail)"
+        />
+      </van-cell>
+    </van-cell-group>
+
+    <view class="form-section-title">业务详情</view>
+    <van-cell-group inset>
+      <van-field
+        :value="form.owner"
+        label="负责人"
+        placeholder="请输入负责人"
+        @change="(e) => (form.owner = e.detail)"
+      />
+      <van-field
+        :value="form.date"
+        label="业务日期"
+        placeholder="YYYY-MM-DD"
+        @change="(e) => (form.date = e.detail)"
+      />
+      <van-field
+        :value="form.amount"
+        type="digit"
+        label="金额"
+        placeholder="0.00"
+        @change="(e) => (form.amount = e.detail)"
+      />
+      <van-field
+        :value="form.remark"
+        label="备注"
+        type="textarea"
+        placeholder="选填,最多 200 字"
+        maxlength="200"
+        show-word-limit
+        autosize
+        @change="(e) => (form.remark = e.detail)"
+      />
+    </van-cell-group>
+
+    <view class="form-tip">必填项校验失败时 Toast 提示。底栏固定「取消 / 保存」。</view>
+
+    <view class="form-footer">
+      <van-button class="btn-flex" plain hairline type="default" @click="goBack">取消</van-button>
+      <van-button class="btn-flex" type="primary" color="#0a9f6d" round @click="onSave">
+        保存
+      </van-button>
+    </view>
+
+    <van-action-sheet
+      :show="showType"
+      :actions="typeActions"
+      cancel-text="取消"
+      @close="showType = false"
+      @select="onTypeSelect"
+      @cancel="showType = false"
+    />
+  </view>
+</template>
+
+<script>
+  import { getById } from '../../utils/biz-mock.js'
+
+  export default {
+    data() {
+      return {
+        mode: 'create',
+        showType: false,
+        typeActions: [
+          { name: '采集协议' },
+          { name: '样本接收' },
+          { name: '质检工单' },
+          { name: '出库申请' }
+        ],
+        form: {
+          name: '',
+          code: '',
+          typeLabel: '',
+          urgent: false,
+          owner: '张三',
+          date: '',
+          amount: '',
+          remark: ''
+        }
+      }
+    },
+    onLoad(query) {
+      this.mode = query.mode === 'edit' ? 'edit' : 'create'
+      if (this.mode === 'edit' && query.id) {
+        const item = getById(query.id)
+        this.form = {
+          name: item.title,
+          code: item.code,
+          typeLabel: item.typeLabel,
+          urgent: item.tagType === 'danger',
+          owner: item.owner,
+          date: '2026-08-10',
+          amount: (item.amount || '').replace(/,/g, ''),
+          remark: item.desc
+        }
+      }
+    },
+    methods: {
+      goBack() {
+        uni.navigateBack({
+          fail: () => uni.reLaunch({ url: '/pages/demo/list' })
+        })
+      },
+      onTypeSelect(e) {
+        this.form.typeLabel = e.detail.name
+        this.showType = false
+      },
+      onSave() {
+        if (!this.form.name || !String(this.form.name).trim()) {
+          uni.showToast({ title: '请填写名称', icon: 'none' })
+          return
+        }
+        uni.showToast({
+          title: this.mode === 'create' ? '已保存(新增)' : '已保存(编辑)',
+          icon: 'success'
+        })
+        setTimeout(() => this.goBack(), 500)
+      }
+    }
+  }
+</script>

+ 101 - 0
mobile-h5/pages/demo/list.vue

@@ -0,0 +1,101 @@
+<template>
+  <view class="page-shell">
+    <view class="list-hero">
+      <van-nav-bar
+        title="业务列表"
+        left-arrow
+        :border="false"
+        @click-left="goHome"
+      >
+        <template #right>
+          <van-icon name="plus" size="20px" color="#fff" @click="goCreate" />
+        </template>
+      </van-nav-bar>
+      <view class="list-hero-copy">
+        <view class="eyebrow">WORKSPACE</view>
+        <view class="title">今日待办一览</view>
+        <view class="subline">
+          <text>当前展示</text>
+          <text class="em">{{ list.length }} 条</text>
+          <text v-if="keyword">· 已筛选</text>
+        </view>
+      </view>
+      <view class="list-search">
+        <van-search
+          :value="keyword"
+          shape="round"
+          background="transparent"
+          placeholder="搜索单号 / 名称 / 负责人"
+          @change="onSearchChange"
+          @search="onSearchChange"
+        />
+      </view>
+    </view>
+
+    <view class="list-filter-dock">
+      <view class="seg-tabs">
+        <view
+          v-for="(tab, idx) in tabs"
+          :key="tab.key"
+          class="seg-tab"
+          :class="{ active: listTab === idx }"
+          @click="listTab = idx"
+        >
+          <text class="lab">{{ tab.label }}</text>
+          <text class="count">{{ tabCount(idx) }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="list-wrap">
+      <BizListCard
+        v-for="item in list"
+        :key="item.id"
+        :item="item"
+        @view="goDetail"
+        @action="goEdit"
+      />
+      <van-empty v-if="!list.length" description="暂无数据" />
+      <view v-else class="list-end">已经到底了</view>
+    </view>
+  </view>
+</template>
+
+<script>
+  import BizListCard from '../../components/BizListCard.vue'
+  import { filterByTab, tabCount, LIST_TABS } from '../../utils/biz-mock.js'
+
+  export default {
+    components: { BizListCard },
+    data() {
+      return {
+        keyword: '',
+        listTab: 0,
+        tabs: LIST_TABS
+      }
+    },
+    computed: {
+      list() {
+        return filterByTab(this.listTab, this.keyword)
+      }
+    },
+    methods: {
+      tabCount,
+      onSearchChange(e) {
+        this.keyword = (e && e.detail !== undefined ? e.detail : e) || ''
+      },
+      goHome() {
+        uni.reLaunch({ url: '/pages/index/index' })
+      },
+      goCreate() {
+        uni.navigateTo({ url: '/pages/demo/form?mode=create' })
+      },
+      goDetail(item) {
+        uni.navigateTo({ url: `/pages/demo/detail?id=${item.id}` })
+      },
+      goEdit(item) {
+        uni.navigateTo({ url: `/pages/demo/form?mode=edit&id=${item.id}` })
+      }
+    }
+  }
+</script>

+ 100 - 0
mobile-h5/pages/index/index.vue

@@ -0,0 +1,100 @@
+<template>
+  <view class="page-shell">
+    <view class="home-header">
+      <view class="brand">中科兴皖 · 移动工作台</view>
+      <view class="hello">你好,{{ userName }}</view>
+      <view class="meta">
+        <text>{{ roleName }}</text>
+        <view class="dot" />
+        <text>{{ deptName }}</text>
+      </view>
+    </view>
+
+    <view class="home-body">
+      <view class="perm-banner">
+        菜单卡片按角色权限动态渲染;无权限的功能入口不会出现在宫格中。
+      </view>
+
+      <view class="panel">
+        <view class="panel-hd">
+          <text class="panel-title">功能菜单</text>
+          <text class="hint">共 {{ menus.length }} 项</text>
+        </view>
+        <MenuGrid v-if="menus.length" :menus="menus" @select="onMenuSelect" />
+        <van-empty v-else description="暂无可用菜单权限" />
+      </view>
+
+      <TodoPanel
+        :stats="todoStats"
+        :items="todoItems"
+        @more="goList"
+        @stat-click="goList"
+        @item-click="goList"
+      />
+    </view>
+  </view>
+</template>
+
+<script>
+  import MenuGrid from '../../components/MenuGrid.vue'
+  import TodoPanel from '../../components/TodoPanel.vue'
+  import {
+    filterMenusByPermission,
+    DEFAULT_GRANTED_IDS
+  } from '../../utils/menu-mock.js'
+
+  export default {
+    components: { MenuGrid, TodoPanel },
+    data() {
+      return {
+        userName: '张三',
+        roleName: '业务专员',
+        deptName: '样本运营部',
+        menus: filterMenusByPermission(DEFAULT_GRANTED_IDS),
+        todoStats: { approve: 3, pay: 1, alert: 1 },
+        todoItems: [
+          {
+            id: 't1',
+            title: '采集协议审批',
+            desc: '王女士 · 分期首期待复核',
+            tag: '待审批',
+            tagType: 'warning',
+            time: '09:20',
+            icon: 'todo-list-o',
+            iconClass: 'i-approve'
+          },
+          {
+            id: 't2',
+            title: '缴费确认',
+            desc: 'XY20260810001 · ¥12,800',
+            tag: '待确认',
+            tagType: 'warning',
+            time: '昨天',
+            icon: 'balance-list-o',
+            iconClass: 'i-pay'
+          },
+          {
+            id: 't3',
+            title: '质检异常',
+            desc: 'QC-8821 · 结论待录入',
+            tag: '异常',
+            tagType: 'danger',
+            time: '08-08',
+            icon: 'warning-o',
+            iconClass: 'i-alert'
+          }
+        ]
+      }
+    },
+    methods: {
+      onMenuSelect(m) {
+        if (m.path) {
+          uni.navigateTo({ url: m.path })
+        }
+      },
+      goList() {
+        uni.navigateTo({ url: '/pages/demo/list' })
+      }
+    }
+  }
+</script>

+ 185 - 1
mobile-h5/styles/components/attach.scss

@@ -1 +1,185 @@
-// Task 4: attachment styles (.d-attach-list, .d-attach-upload, .d-attach-card, file type classes)
+.d-attach-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.d-attach-upload {
+  width: 100%;
+  border: 3rpx dashed #9ad9be;
+  border-radius: 28rpx;
+  background: linear-gradient(135deg, rgba(233, 249, 242, 0.95), rgba(255, 255, 255, 0.95));
+  padding: 28rpx 24rpx;
+  display: flex;
+  align-items: center;
+  gap: 24rpx;
+}
+
+.d-attach-upload:active {
+  border-color: #0a9f6d;
+}
+
+.d-attach-upload .up-icon {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 24rpx;
+  background: linear-gradient(145deg, #2ecf8f, #0a9f6d);
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 44rpx;
+  box-shadow: 0 12rpx 28rpx rgba(10, 159, 109, 0.28);
+  flex-shrink: 0;
+}
+
+.d-attach-upload .up-text {
+  flex: 1;
+  min-width: 0;
+}
+
+.d-attach-upload .up-title {
+  font-size: 26rpx;
+  font-weight: 700;
+  color: #087f57;
+}
+
+.d-attach-upload .up-sub {
+  margin-top: 4rpx;
+  font-size: 22rpx;
+  color: #6b7b74;
+  line-height: 1.4;
+}
+
+.d-attach-card {
+  display: flex;
+  align-items: center;
+  gap: 24rpx;
+  padding: 24rpx;
+  border-radius: 28rpx;
+  background: #fff;
+  border: 1rpx solid #e4eee9;
+  box-shadow: 0 8rpx 28rpx rgba(20, 55, 40, 0.05);
+  position: relative;
+  overflow: hidden;
+}
+
+.d-attach-card::before {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: 0;
+  bottom: 0;
+  width: 6rpx;
+  background: var(--file-accent, #0a9f6d);
+}
+
+.d-attach-card.ft-pdf {
+  --file-accent: #d14343;
+}
+.d-attach-card.ft-img {
+  --file-accent: #0a9f6d;
+}
+.d-attach-card.ft-xls {
+  --file-accent: #189b65;
+}
+.d-attach-card.ft-doc {
+  --file-accent: #3b82f6;
+}
+.d-attach-card.ft-other {
+  --file-accent: #d6a343;
+}
+
+.d-attach-card .file-icon {
+  width: 92rpx;
+  height: 92rpx;
+  border-radius: 26rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 2rpx;
+  flex-shrink: 0;
+  color: #fff;
+  font-weight: 800;
+}
+
+.d-attach-card.ft-pdf .file-icon {
+  background: linear-gradient(145deg, #f07171, #d14343);
+}
+.d-attach-card.ft-img .file-icon {
+  background: linear-gradient(145deg, #3dca96, #0a9f6d);
+}
+.d-attach-card.ft-xls .file-icon {
+  background: linear-gradient(145deg, #4fbf8a, #189b65);
+}
+.d-attach-card.ft-doc .file-icon {
+  background: linear-gradient(145deg, #6ea8ff, #3b82f6);
+}
+.d-attach-card.ft-other .file-icon {
+  background: linear-gradient(145deg, #e4bc6a, #d6a343);
+}
+
+.d-attach-card .file-icon .ext {
+  font-size: 20rpx;
+  letter-spacing: 0.04em;
+  line-height: 1;
+  text-transform: uppercase;
+}
+
+.d-attach-card .file-body {
+  flex: 1;
+  min-width: 0;
+}
+
+.d-attach-card .file-name {
+  font-size: 26rpx;
+  font-weight: 650;
+  color: #173128;
+  line-height: 1.35;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.d-attach-card .file-meta {
+  margin-top: 8rpx;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+  font-size: 22rpx;
+  color: #8aa198;
+}
+
+.d-attach-card .file-ops {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+  flex-shrink: 0;
+}
+
+.d-attach-card .file-ops .op-btn {
+  width: 64rpx;
+  height: 64rpx;
+  border-radius: 20rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 32rpx;
+}
+
+.d-attach-card .file-ops .btn-preview {
+  background: var(--brand-green-soft, #e9f9f2);
+  color: var(--brand-green-dark, #087f57);
+}
+
+.d-attach-card .file-ops .btn-download {
+  background: #f0f5f3;
+  color: #4d635a;
+}
+
+.d-attach-card .file-ops .icon-download {
+  width: 28rpx;
+  height: 28rpx;
+  display: block;
+}

+ 241 - 1
mobile-h5/styles/components/detail.scss

@@ -1 +1,241 @@
-// Task 4: detail page styles (.detail-page, .detail-hero, .detail-metric-dock, .detail-section, .detail-footer)
+@import '../mixins.scss';
+
+.detail-page {
+  min-height: 100vh;
+  background:
+    radial-gradient(760rpx 400rpx at 100% 8%, rgba(214, 163, 67, 0.14), transparent 55%),
+    linear-gradient(180deg, #e8f5ef 0%, #f4f7f6 42%, #f7f9f8 100%);
+  padding-bottom: 176rpx;
+}
+
+.detail-hero {
+  position: relative;
+  overflow: hidden;
+  padding: 88rpx 28rpx 56rpx;
+  border-radius: 0 0 56rpx 56rpx;
+  color: #fff;
+}
+
+.detail-hero.status-warning {
+  background: linear-gradient(155deg, #a67b20 0%, #d6a343 45%, #e8c06a 100%);
+}
+.detail-hero.status-primary {
+  @include hero-green;
+  border-radius: 0 0 56rpx 56rpx;
+}
+.detail-hero.status-danger {
+  background: linear-gradient(155deg, #9c2f2f 0%, #d14343 50%, #e36a6a 100%);
+}
+.detail-hero.status-success {
+  background: linear-gradient(155deg, #0f6b48 0%, #189b65 52%, #3bb883 100%);
+}
+
+.detail-hero-body {
+  position: relative;
+  z-index: 1;
+  margin-top: 12rpx;
+}
+
+.detail-hero .type-line {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  margin-bottom: 20rpx;
+}
+
+.detail-hero .type-chip,
+.detail-hero .status-chip {
+  font-size: 22rpx;
+  font-weight: 650;
+  padding: 6rpx 18rpx;
+  border-radius: 999rpx;
+}
+
+.detail-hero .type-chip {
+  background: rgba(255, 255, 255, 0.2);
+  color: #fff;
+}
+
+.detail-hero .status-chip {
+  background: #fff;
+  color: #173128;
+}
+
+.detail-hero .hero-title {
+  font-size: 40rpx;
+  font-weight: 750;
+  line-height: 1.35;
+}
+
+.detail-hero .code {
+  margin-top: 16rpx;
+  font-size: 24rpx;
+  opacity: 0.92;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+.detail-metric-dock {
+  margin: -36rpx 24rpx 0;
+  position: relative;
+  z-index: 3;
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 16rpx;
+  padding: 24rpx;
+  border-radius: 36rpx;
+  background: rgba(255, 255, 255, 0.94);
+  border: 1rpx solid rgba(220, 235, 228, 0.95);
+  box-shadow: 0 24rpx 60rpx rgba(15, 55, 40, 0.1);
+}
+
+.detail-metric {
+  text-align: center;
+  padding: 8rpx 4rpx;
+}
+
+.detail-metric .lab {
+  display: block;
+  font-size: 20rpx;
+  color: #8aa198;
+  margin-bottom: 8rpx;
+}
+
+.detail-metric .val {
+  display: block;
+  font-size: 28rpx;
+  font-weight: 750;
+  color: #173128;
+  line-height: 1.25;
+  word-break: break-all;
+}
+
+.detail-metric .val.money {
+  color: #9a7424;
+  font-variant-numeric: tabular-nums;
+}
+
+.detail-body {
+  padding: 32rpx 24rpx 0;
+}
+
+.detail-section {
+  @include surface-card;
+  border-radius: 36rpx;
+  padding: 28rpx 28rpx 24rpx;
+  margin-bottom: 24rpx;
+}
+
+.detail-section .sec-hd {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 24rpx;
+}
+
+.detail-section .sec-hd .sec-title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #173128;
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+
+.detail-section .sec-hd .sec-title::before {
+  content: '';
+  width: 8rpx;
+  height: 28rpx;
+  border-radius: 999rpx;
+  background: linear-gradient(180deg, #12b37a, #087f57);
+}
+
+.detail-section .sec-hd .sec-more {
+  font-size: 24rpx;
+  color: var(--brand-green, #0a9f6d);
+}
+
+.kv-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.kv-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 24rpx;
+  font-size: 26rpx;
+  line-height: 1.45;
+}
+
+.kv-row .k {
+  width: 144rpx;
+  flex-shrink: 0;
+  color: #8aa198;
+}
+
+.kv-row .v {
+  flex: 1;
+  color: #173128;
+  font-weight: 550;
+  word-break: break-all;
+}
+
+.detail-note {
+  margin-top: 24rpx;
+  padding: 20rpx 24rpx;
+  border-radius: 24rpx;
+  background: linear-gradient(135deg, #fff8ec, #fffdf8);
+  border: 1rpx solid #f0e0b8;
+  color: #8a6520;
+  font-size: 24rpx;
+  line-height: 1.5;
+}
+
+.pay-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 20rpx;
+  padding: 20rpx 24rpx;
+  border-radius: 24rpx;
+  background: #f7faf9;
+  margin-bottom: 16rpx;
+}
+
+.pay-row .name {
+  font-size: 26rpx;
+  font-weight: 650;
+  color: #173128;
+}
+
+.pay-row .sub {
+  margin-top: 4rpx;
+  font-size: 22rpx;
+  color: #8aa198;
+}
+
+.pay-row .amt {
+  font-size: 28rpx;
+  font-weight: 750;
+  color: #9a7424;
+  font-variant-numeric: tabular-nums;
+  text-align: right;
+}
+
+.detail-footer {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  gap: 20rpx;
+  padding: 24rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
+  background: rgba(255, 255, 255, 0.96);
+  border-top: 1rpx solid var(--border, #dcebe4);
+  z-index: 20;
+}
+
+.detail-footer .btn-flex {
+  flex: 1;
+}

+ 142 - 1
mobile-h5/styles/components/flow.scss

@@ -1 +1,142 @@
-// Task 4: flow timeline styles (.flow-list, .flow-item, .flow-dot, .flow-card)
+.flow-list {
+  display: flex;
+  flex-direction: column;
+}
+
+.flow-item {
+  position: relative;
+  display: flex;
+  gap: 24rpx;
+  padding-bottom: 28rpx;
+}
+
+.flow-item:last-child {
+  padding-bottom: 0;
+}
+
+.flow-item:not(:last-child) .flow-rail::after {
+  content: '';
+  position: absolute;
+  left: 50%;
+  top: 72rpx;
+  bottom: -28rpx;
+  width: 4rpx;
+  transform: translateX(-50%);
+  background: linear-gradient(180deg, rgba(10, 159, 109, 0.45), rgba(220, 235, 228, 0.9));
+  border-radius: 4rpx;
+}
+
+.flow-rail {
+  position: relative;
+  width: 72rpx;
+  flex-shrink: 0;
+  display: flex;
+  justify-content: center;
+}
+
+.flow-dot {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 24rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 32rpx;
+  color: #fff;
+  box-shadow: 0 12rpx 28rpx rgba(8, 127, 87, 0.2);
+  position: relative;
+  z-index: 1;
+}
+
+.flow-item.done .flow-dot {
+  background: linear-gradient(145deg, #2ecf8f, #0a9f6d);
+}
+.flow-item.warn .flow-dot {
+  background: linear-gradient(145deg, #ecc06a, #d6a343);
+  box-shadow: 0 12rpx 28rpx rgba(214, 163, 67, 0.28);
+}
+.flow-item.danger .flow-dot {
+  background: linear-gradient(145deg, #ef7a7a, #d14343);
+  box-shadow: 0 12rpx 28rpx rgba(209, 67, 67, 0.25);
+}
+
+.flow-card {
+  flex: 1;
+  min-width: 0;
+  border-radius: 28rpx;
+  padding: 22rpx 24rpx;
+  background: linear-gradient(160deg, #f7fbf9 0%, #ffffff 55%);
+  border: 1rpx solid #e4eee9;
+  box-shadow: 0 8rpx 24rpx rgba(20, 55, 40, 0.04);
+}
+
+.flow-item.warn .flow-card {
+  background: linear-gradient(160deg, #fff9ef 0%, #ffffff 60%);
+  border-color: #f0e0b8;
+}
+.flow-item.danger .flow-card {
+  background: linear-gradient(160deg, #fff4f4 0%, #ffffff 60%);
+  border-color: #f3d0d0;
+}
+
+.flow-card .flow-top {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16rpx;
+  margin-bottom: 8rpx;
+}
+
+.flow-card .flow-title {
+  font-size: 26rpx;
+  font-weight: 700;
+  color: #173128;
+  line-height: 1.35;
+}
+
+.flow-card .flow-badge {
+  flex-shrink: 0;
+  font-size: 20rpx;
+  font-weight: 700;
+  padding: 4rpx 14rpx;
+  border-radius: 999rpx;
+}
+
+.flow-item.done .flow-badge {
+  background: rgba(10, 159, 109, 0.12);
+  color: #087f57;
+}
+.flow-item.warn .flow-badge {
+  background: rgba(214, 163, 67, 0.16);
+  color: #8f6718;
+}
+.flow-item.danger .flow-badge {
+  background: rgba(209, 67, 67, 0.12);
+  color: #b13535;
+}
+
+.flow-card .flow-desc {
+  font-size: 24rpx;
+  color: #5d746a;
+  line-height: 1.5;
+}
+
+.flow-card .flow-meta {
+  margin-top: 16rpx;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+  align-items: center;
+}
+
+.flow-card .flow-meta .chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 8rpx;
+  font-size: 22rpx;
+  color: #6b7b74;
+  background: rgba(255, 255, 255, 0.9);
+  border: 1rpx solid #e8f0ec;
+  border-radius: 999rpx;
+  padding: 4rpx 16rpx;
+}

+ 116 - 1
mobile-h5/styles/components/form-shell.scss

@@ -1 +1,116 @@
-// Task 4: form shell styles (.form-page, .form-section-title, .form-footer)
+@import '../mixins.scss';
+
+.form-page {
+  min-height: 100vh;
+  background: var(--bg-page, #f5f8f7);
+  padding-bottom: 176rpx;
+}
+
+.form-nav {
+  @include hero-green;
+  padding: 88rpx 16rpx 20rpx;
+}
+
+.form-section-title {
+  margin: 28rpx 32rpx 16rpx;
+  font-size: 26rpx;
+  font-weight: 600;
+  color: var(--text-muted, #6b7b74);
+}
+
+.form-tip {
+  margin: 16rpx 32rpx 0;
+  font-size: 22rpx;
+  color: var(--text-muted, #6b7b74);
+  line-height: 1.45;
+}
+
+.form-footer {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  gap: 20rpx;
+  padding: 24rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
+  background: rgba(255, 255, 255, 0.96);
+  border-top: 1rpx solid var(--border, #dcebe4);
+  z-index: 20;
+}
+
+.form-footer .btn-flex {
+  flex: 1;
+}
+
+.home-header {
+  @include hero-green;
+  padding: 108rpx 32rpx 56rpx;
+}
+
+.home-header .brand {
+  font-size: 24rpx;
+  opacity: 0.88;
+  letter-spacing: 0.04em;
+  margin-bottom: 16rpx;
+}
+
+.home-header .hello {
+  font-size: 44rpx;
+  font-weight: 700;
+}
+
+.home-header .meta {
+  margin-top: 20rpx;
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  font-size: 24rpx;
+  opacity: 0.92;
+}
+
+.home-header .meta .dot {
+  width: 8rpx;
+  height: 8rpx;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.7);
+}
+
+.home-body {
+  padding: 28rpx 24rpx 48rpx;
+}
+
+.panel {
+  @include surface-card;
+  padding: 28rpx 24rpx;
+  margin-bottom: 24rpx;
+}
+
+.panel-hd {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 24rpx;
+  padding: 0 8rpx;
+}
+
+.panel-hd .panel-title {
+  font-size: 30rpx;
+  font-weight: 650;
+  color: var(--text-primary, #1f2a26);
+}
+
+.panel-hd .hint {
+  font-size: 22rpx;
+  color: var(--text-muted, #6b7b74);
+}
+
+.perm-banner {
+  padding: 20rpx 24rpx;
+  border-radius: 24rpx;
+  background: var(--barley-yellow-soft, #fff7e8);
+  border: 1rpx solid #f0e0b8;
+  font-size: 24rpx;
+  color: #8a6520;
+  margin-bottom: 24rpx;
+  line-height: 1.45;
+}

+ 374 - 1
mobile-h5/styles/components/list.scss

@@ -1 +1,374 @@
-// Task 4: list page styles (.list-hero, .list-filter-dock, .seg-tabs, .biz-card, status variants)
+@import '../mixins.scss';
+
+.list-hero {
+  @include hero-green;
+  padding: 88rpx 28rpx 44rpx;
+}
+
+.list-hero::after {
+  content: '';
+  position: absolute;
+  width: 360rpx;
+  height: 360rpx;
+  right: -100rpx;
+  bottom: -140rpx;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.08);
+  pointer-events: none;
+}
+
+.list-hero-copy {
+  position: relative;
+  z-index: 1;
+  margin: 8rpx 4rpx 28rpx;
+  color: #fff;
+}
+
+.list-hero-copy .eyebrow {
+  font-size: 22rpx;
+  letter-spacing: 0.08em;
+  opacity: 0.82;
+  margin-bottom: 8rpx;
+}
+
+.list-hero-copy .title {
+  font-size: 44rpx;
+  font-weight: 700;
+}
+
+.list-hero-copy .subline {
+  margin-top: 12rpx;
+  font-size: 24rpx;
+  opacity: 0.88;
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+
+.list-hero-copy .subline .em {
+  font-style: normal;
+  font-weight: 700;
+  background: rgba(255, 255, 255, 0.18);
+  border-radius: 999rpx;
+  padding: 2rpx 16rpx;
+}
+
+.list-search {
+  position: relative;
+  z-index: 1;
+}
+
+.list-filter-dock {
+  margin: -28rpx 24rpx 0;
+  position: relative;
+  z-index: 3;
+  background: rgba(255, 255, 255, 0.92);
+  border: 1rpx solid rgba(220, 235, 228, 0.95);
+  border-radius: 36rpx;
+  padding: 20rpx;
+  box-shadow: 0 20rpx 56rpx rgba(15, 55, 40, 0.08);
+}
+
+.seg-tabs {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 8rpx;
+  padding: 8rpx;
+  border-radius: 28rpx;
+  background: #eef6f2;
+}
+
+.seg-tab {
+  border-radius: 22rpx;
+  padding: 16rpx 8rpx;
+  color: #6b7b74;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4rpx;
+  font-size: 24rpx;
+}
+
+.seg-tab .count {
+  font-size: 22rpx;
+  font-weight: 700;
+  color: #9aada4;
+  font-variant-numeric: tabular-nums;
+}
+
+.seg-tab.active {
+  background: #fff;
+  color: var(--brand-green-dark, #087f57);
+  font-weight: 650;
+  box-shadow: 0 8rpx 24rpx rgba(8, 127, 87, 0.14);
+}
+
+.seg-tab.active .count {
+  color: var(--brand-green, #0a9f6d);
+}
+
+.list-wrap {
+  padding: 32rpx 24rpx 0;
+}
+
+.biz-card {
+  position: relative;
+  border-radius: 40rpx;
+  margin-bottom: 28rpx;
+  overflow: hidden;
+  box-shadow: 0 20rpx 56rpx rgba(20, 55, 40, 0.08);
+  isolation: isolate;
+}
+
+.biz-card.status-warning {
+  background: linear-gradient(145deg, #fff9ef 0%, #ffffff 46%, #fffaf3 100%);
+}
+.biz-card.status-primary {
+  background: linear-gradient(145deg, #eaf8f1 0%, #ffffff 48%, #f3fbf7 100%);
+}
+.biz-card.status-danger {
+  background: linear-gradient(145deg, #fff1f1 0%, #ffffff 48%, #fff7f7 100%);
+}
+.biz-card.status-success {
+  background: linear-gradient(145deg, #eef8f3 0%, #ffffff 50%, #f6fbf8 100%);
+}
+
+.biz-card .card-inner {
+  position: relative;
+  z-index: 1;
+  padding: 28rpx 28rpx 24rpx;
+}
+
+.biz-card .row-top {
+  display: flex;
+  align-items: flex-start;
+  gap: 20rpx;
+  margin-bottom: 24rpx;
+}
+
+.biz-card .type-icon {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 28rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  font-size: 44rpx;
+  color: #fff;
+}
+
+.biz-card.status-warning .type-icon {
+  background: linear-gradient(145deg, #ffe8b8, #f6d089);
+  color: #7a5610;
+}
+.biz-card.status-primary .type-icon {
+  background: linear-gradient(145deg, #8fe0bf, #0a9f6d);
+}
+.biz-card.status-danger .type-icon {
+  background: linear-gradient(145deg, #ffb4b4, #e85a5a);
+}
+.biz-card.status-success .type-icon {
+  background: linear-gradient(145deg, #b8efd4, #189b65);
+}
+
+.biz-card .title-wrap {
+  flex: 1;
+  min-width: 0;
+}
+
+.biz-card .type-chip {
+  display: inline-flex;
+  font-size: 20rpx;
+  font-weight: 650;
+  letter-spacing: 0.04em;
+  padding: 4rpx 14rpx;
+  border-radius: 999rpx;
+  margin-bottom: 10rpx;
+}
+
+.biz-card.status-warning .type-chip {
+  background: rgba(214, 163, 67, 0.16);
+  color: #8f6718;
+}
+.biz-card.status-primary .type-chip {
+  background: rgba(10, 159, 109, 0.12);
+  color: #087f57;
+}
+.biz-card.status-danger .type-chip {
+  background: rgba(209, 67, 67, 0.12);
+  color: #b13535;
+}
+.biz-card.status-success .type-chip {
+  background: rgba(24, 155, 101, 0.12);
+  color: #147a50;
+}
+
+.biz-card .title {
+  font-size: 30rpx;
+  font-weight: 700;
+  line-height: 1.35;
+  color: #173128;
+}
+
+.biz-card .status-pill {
+  flex-shrink: 0;
+  font-size: 22rpx;
+  font-weight: 700;
+  padding: 8rpx 18rpx;
+  border-radius: 999rpx;
+}
+
+.biz-card.status-warning .status-pill {
+  background: #fff;
+  color: #a67b20;
+  box-shadow: 0 4rpx 16rpx rgba(214, 163, 67, 0.22);
+}
+.biz-card.status-primary .status-pill {
+  background: #0a9f6d;
+  color: #fff;
+}
+.biz-card.status-danger .status-pill {
+  background: #d14343;
+  color: #fff;
+}
+.biz-card.status-success .status-pill {
+  background: rgba(24, 155, 101, 0.12);
+  color: #147a50;
+}
+
+.biz-card .body-row {
+  display: flex;
+  gap: 24rpx;
+  align-items: stretch;
+}
+
+.biz-card .desc-box {
+  flex: 1;
+  margin: 0;
+  padding: 20rpx 22rpx;
+  border-radius: 24rpx;
+  background: rgba(255, 255, 255, 0.72);
+  border: 1rpx solid rgba(255, 255, 255, 0.9);
+  font-size: 24rpx;
+  color: #3f564c;
+  line-height: 1.5;
+}
+
+.biz-card .side-metric {
+  width: 172rpx;
+  flex-shrink: 0;
+  border-radius: 24rpx;
+  padding: 16rpx;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: flex-end;
+  text-align: right;
+  background: rgba(255, 255, 255, 0.65);
+  border: 1rpx solid rgba(255, 255, 255, 0.95);
+}
+
+.biz-card .side-metric .amt-label {
+  font-size: 20rpx;
+  color: #8aa198;
+}
+
+.biz-card .side-metric .amt {
+  margin-top: 4rpx;
+  font-size: 30rpx;
+  font-weight: 800;
+  color: #173128;
+  font-variant-numeric: tabular-nums;
+}
+
+.biz-card.status-warning .side-metric .amt {
+  color: #9a7424;
+}
+.biz-card.status-primary .side-metric .amt {
+  color: #087f57;
+}
+.biz-card.status-danger .side-metric .amt {
+  color: #b13535;
+}
+
+.biz-card .side-metric .time {
+  margin-top: 12rpx;
+  font-size: 20rpx;
+  color: #8aa198;
+}
+
+.biz-card .meta-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+  margin-top: 24rpx;
+}
+
+.biz-card .meta-row .mi {
+  display: inline-flex;
+  align-items: center;
+  gap: 8rpx;
+  font-size: 22rpx;
+  color: #5d746a;
+  background: rgba(255, 255, 255, 0.7);
+  border-radius: 999rpx;
+  padding: 6rpx 16rpx;
+}
+
+.biz-card .code-pill {
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 20rpx;
+}
+
+.biz-card .footer {
+  margin-top: 24rpx;
+  padding-top: 20rpx;
+  border-top: 1rpx dashed rgba(140, 170, 155, 0.28);
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 16rpx;
+}
+
+.biz-card .footer .left-hint {
+  font-size: 22rpx;
+  color: #7f968c;
+  display: inline-flex;
+  align-items: center;
+  gap: 8rpx;
+}
+
+.biz-card .footer .left-hint .dot {
+  width: 12rpx;
+  height: 12rpx;
+  border-radius: 50%;
+  background: currentColor;
+  opacity: 0.75;
+}
+
+.biz-card.status-warning .footer .left-hint {
+  color: #a67b20;
+}
+.biz-card.status-primary .footer .left-hint {
+  color: #0a9f6d;
+}
+.biz-card.status-danger .footer .left-hint {
+  color: #d14343;
+}
+.biz-card.status-success .footer .left-hint {
+  color: #189b65;
+}
+
+.biz-card .footer .ops {
+  display: flex;
+  gap: 16rpx;
+}
+
+.list-end {
+  padding: 16rpx 0 56rpx;
+  text-align: center;
+  font-size: 22rpx;
+  color: #8aa198;
+  letter-spacing: 0.08em;
+}

+ 54 - 1
mobile-h5/styles/components/menu-grid.scss

@@ -1 +1,54 @@
-// Task 4: menu-grid styles (.menu-grid, .menu-card, .menu-icon, .menu-title)
+@import '../mixins.scss';
+
+.menu-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 20rpx 12rpx;
+}
+
+.menu-card {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: flex-start;
+  gap: 16rpx;
+  min-height: 168rpx;
+  padding: 20rpx 8rpx 12rpx;
+  border-radius: 24rpx;
+}
+
+.menu-card:active {
+  background: var(--brand-green-soft, #e9f9f2);
+}
+
+.menu-icon {
+  width: 96rpx;
+  height: 96rpx;
+  border-radius: 28rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--brand-green-soft, #e9f9f2);
+  color: var(--brand-green-dark, #087f57);
+  font-size: 48rpx;
+  box-shadow: inset 0 0 0 1rpx rgba(10, 159, 109, 0.08);
+}
+
+.menu-icon.tone-yellow {
+  background: var(--barley-yellow-soft, #fff7e8);
+  color: #a67b20;
+}
+
+.menu-icon.tone-slate {
+  background: #eef2f1;
+  color: #3d524a;
+}
+
+.menu-title {
+  font-size: 24rpx;
+  line-height: 1.25;
+  color: var(--text-primary, #1f2a26);
+  text-align: center;
+  max-width: 144rpx;
+  word-break: break-all;
+}

+ 184 - 1
mobile-h5/styles/components/todo.scss

@@ -1 +1,184 @@
-// Task 4: todo panel styles (.todo-panel, .todo-stats, .todo-stat, .todo-list, .todo-item)
+.todo-panel {
+  padding: 28rpx 24rpx 24rpx;
+  background: linear-gradient(165deg, rgba(233, 249, 242, 0.95) 0%, #fff 42%), #fff;
+}
+
+.todo-stats {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 16rpx;
+  margin-bottom: 24rpx;
+}
+
+.todo-stat {
+  position: relative;
+  border-radius: 24rpx;
+  padding: 24rpx 20rpx 20rpx;
+  text-align: left;
+  background: #fff;
+  box-shadow: 0 4rpx 20rpx rgba(8, 127, 87, 0.06);
+  border: 1rpx solid rgba(220, 235, 228, 0.9);
+  overflow: hidden;
+}
+
+.todo-stat::before {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: 0;
+  bottom: 0;
+  width: 6rpx;
+  border-radius: 6rpx 0 0 6rpx;
+}
+
+.todo-stat.tone-warn::before {
+  background: var(--warning, #cf8a19);
+}
+.todo-stat.tone-pay::before {
+  background: var(--barley-yellow, #d6a343);
+}
+.todo-stat.tone-danger::before {
+  background: var(--danger, #d14343);
+}
+
+.todo-stat.tone-warn {
+  background: linear-gradient(180deg, #fffaf0 0%, #fff 70%);
+}
+.todo-stat.tone-pay {
+  background: linear-gradient(180deg, #fff8ec 0%, #fff 70%);
+}
+.todo-stat.tone-danger {
+  background: linear-gradient(180deg, #fff5f5 0%, #fff 70%);
+}
+
+.todo-stat .num {
+  display: block;
+  font-size: 44rpx;
+  font-weight: 700;
+  line-height: 1.1;
+  color: var(--text-primary, #1f2a26);
+  font-variant-numeric: tabular-nums;
+}
+
+.todo-stat.tone-warn .num {
+  color: #b77412;
+}
+.todo-stat.tone-pay .num {
+  color: #9a7424;
+}
+.todo-stat.tone-danger .num {
+  color: var(--danger, #d14343);
+}
+
+.todo-stat .label {
+  display: block;
+  margin-top: 8rpx;
+  font-size: 22rpx;
+  color: var(--text-muted, #6b7b74);
+}
+
+.todo-stat .stat-icon {
+  position: absolute;
+  right: 16rpx;
+  top: 16rpx;
+  width: 52rpx;
+  height: 52rpx;
+  border-radius: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 28rpx;
+}
+
+.todo-stat.tone-warn .stat-icon {
+  background: rgba(207, 138, 25, 0.12);
+  color: var(--warning, #cf8a19);
+}
+.todo-stat.tone-pay .stat-icon {
+  background: rgba(214, 163, 67, 0.16);
+  color: #a67b20;
+}
+.todo-stat.tone-danger .stat-icon {
+  background: rgba(209, 67, 67, 0.12);
+  color: var(--danger, #d14343);
+}
+
+.todo-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.todo-item {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+  width: 100%;
+  padding: 20rpx;
+  border: 1rpx solid var(--border, #dcebe4);
+  border-radius: 24rpx;
+  background: #fff;
+}
+
+.todo-item:active {
+  background: var(--brand-green-soft, #e9f9f2);
+  border-color: #b7e5d0;
+}
+
+.todo-item .ti-icon {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 20rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  font-size: 36rpx;
+}
+
+.todo-item .ti-icon.i-approve {
+  background: #fff3e0;
+  color: var(--warning, #cf8a19);
+}
+.todo-item .ti-icon.i-pay {
+  background: var(--barley-yellow-soft, #fff7e8);
+  color: #a67b20;
+}
+.todo-item .ti-icon.i-alert {
+  background: #fdecec;
+  color: var(--danger, #d14343);
+}
+
+.todo-item .ti-body {
+  flex: 1;
+  min-width: 0;
+}
+
+.todo-item .ti-title {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: var(--text-primary, #1f2a26);
+  line-height: 1.3;
+}
+
+.todo-item .ti-desc {
+  margin-top: 4rpx;
+  font-size: 22rpx;
+  color: var(--text-muted, #6b7b74);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.todo-item .ti-right {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 8rpx;
+  flex-shrink: 0;
+}
+
+.todo-item .ti-time {
+  font-size: 20rpx;
+  color: #9aada4;
+}

+ 262 - 0
mobile-h5/utils/biz-mock.js

@@ -0,0 +1,262 @@
+const listData = [
+  {
+    id: 1,
+    title: '脐带血采集协议-王女士',
+    code: 'XY20260810001',
+    typeLabel: '采集协议',
+    icon: 'orders-o',
+    owner: '李四',
+    time: '08-10 09:20',
+    amount: '12,800',
+    progress: '',
+    desc: '分期缴费 · 待财务确认首期款项',
+    status: '待处理',
+    tagType: 'warning',
+    hint: '需尽快确认',
+    customer: '王女士',
+    dept: '样本运营部',
+    createdAt: '2026-08-08 14:20',
+    stepActive: 1,
+    steps: ['建档', '缴费确认', '样本采集', '入库'],
+    payments: [
+      {
+        id: 'p1',
+        name: '第 1 期(首期)',
+        sub: '应缴 08-10 · 微信转账',
+        amount: '6,400',
+        status: '待确认',
+        tagType: 'warning'
+      },
+      {
+        id: 'p2',
+        name: '第 2 期',
+        sub: '应缴 09-10 · 未到期',
+        amount: '6,400',
+        status: '待缴费',
+        tagType: 'primary'
+      }
+    ],
+    attachments: [
+      { name: '协议扫描件.pdf', size: '2.4 MB', time: '08-08 14:18' },
+      { name: '身份证明.jpg', size: '860 KB', time: '08-08 14:19' }
+    ],
+    logs: [
+      {
+        title: '提交审批',
+        desc: '提交采集协议,等待财务确认首期',
+        time: '08-08 14:22',
+        tone: 'done',
+        actor: '李四',
+        icon: 'edit',
+        badge: '已完成'
+      },
+      {
+        title: '流转至财务',
+        desc: '系统自动分配至财务确认岗',
+        time: '08-08 14:23',
+        tone: 'done',
+        actor: '系统',
+        icon: 'cluster-o',
+        badge: '已完成'
+      },
+      {
+        title: '待确认缴费',
+        desc: '首期 ¥6,400 等待财务核对附件',
+        time: '08-10 09:20',
+        tone: 'warn',
+        actor: '财务岗',
+        icon: 'balance-list-o',
+        badge: '进行中'
+      }
+    ]
+  },
+  {
+    id: 2,
+    title: '样本接收单-批次 A12',
+    code: 'YS20260809018',
+    typeLabel: '样本接收',
+    icon: 'logistics',
+    owner: '王五',
+    time: '08-09 16:40',
+    amount: '',
+    progress: '在途',
+    desc: '冷链在途 · 预计今日 18:00 前到达库房',
+    status: '进行中',
+    tagType: 'primary',
+    hint: '跟踪物流',
+    customer: '批次 A12',
+    dept: '仓储物流部',
+    createdAt: '2026-08-09 09:10',
+    stepActive: 2,
+    steps: ['发运', '在途', '到库签收', '入库'],
+    payments: [],
+    attachments: [{ name: '冷链温度记录.pdf', size: '1.1 MB', time: '08-09 11:28' }],
+    logs: [
+      {
+        title: '创建接收单',
+        desc: '创建样本接收单并绑定批次 A12',
+        time: '08-09 09:10',
+        tone: 'done',
+        actor: '王五',
+        icon: 'orders-o',
+        badge: '已完成'
+      },
+      {
+        title: '冷链发出',
+        desc: '承运方已取件,全程温控开启',
+        time: '08-09 11:30',
+        tone: 'done',
+        actor: '承运方',
+        icon: 'logistics',
+        badge: '已完成'
+      },
+      {
+        title: '运输中',
+        desc: '当前位置:城东中转站',
+        time: '08-09 16:40',
+        tone: 'warn',
+        actor: '物流跟踪',
+        icon: 'location-o',
+        badge: '进行中'
+      }
+    ]
+  },
+  {
+    id: 3,
+    title: '质检工单-QC-8821',
+    code: 'ZJ20260808007',
+    typeLabel: '质检工单',
+    icon: 'certificate',
+    owner: '赵六',
+    time: '08-08 11:05',
+    amount: '',
+    progress: '阻塞',
+    desc: '检测结论待录入,阻塞下游入库流程',
+    status: '异常',
+    tagType: 'danger',
+    hint: '优先处理',
+    customer: '样本 S-8821',
+    dept: '质检中心',
+    createdAt: '2026-08-07 16:40',
+    stepActive: 1,
+    steps: ['取样', '检测', '结论录入', '关闭'],
+    payments: [],
+    attachments: [{ name: '初检原始数据.xlsx', size: '428 KB', time: '08-08 10:48' }],
+    logs: [
+      {
+        title: '工单创建',
+        desc: '系统根据接收单自动生成质检工单',
+        time: '08-07 16:40',
+        tone: 'done',
+        actor: '系统',
+        icon: 'add-o',
+        badge: '已完成'
+      },
+      {
+        title: '检测完成',
+        desc: '完成实验操作,待录入结论',
+        time: '08-08 10:50',
+        tone: 'done',
+        actor: '赵六',
+        icon: 'certificate',
+        badge: '已完成'
+      },
+      {
+        title: '异常挂起',
+        desc: '结论超时未录入,已阻塞入库',
+        time: '08-08 11:05',
+        tone: 'danger',
+        actor: '质控规则',
+        icon: 'warning-o',
+        badge: '异常'
+      }
+    ]
+  },
+  {
+    id: 4,
+    title: '出库申请-库位 B-03',
+    code: 'CK20260807002',
+    typeLabel: '库位出库',
+    icon: 'send-gift-o',
+    owner: '张三',
+    time: '08-07 14:22',
+    amount: '',
+    progress: '完结',
+    desc: '复核已完成,可归档关闭',
+    status: '已完成',
+    tagType: 'success',
+    hint: '已完结',
+    customer: '库位 B-03',
+    dept: '仓储物流部',
+    createdAt: '2026-08-06 10:00',
+    stepActive: 3,
+    steps: ['申请', '复核', '出库', '归档'],
+    payments: [],
+    attachments: [{ name: '出库单签收.pdf', size: '980 KB', time: '08-07 14:18' }],
+    logs: [
+      {
+        title: '提交申请',
+        desc: '发起出库申请,指定库位 B-03',
+        time: '08-06 10:00',
+        tone: 'done',
+        actor: '张三',
+        icon: 'edit',
+        badge: '已完成'
+      },
+      {
+        title: '复核通过',
+        desc: '库管复核通过,允许出库',
+        time: '08-07 11:10',
+        tone: 'done',
+        actor: '库管',
+        icon: 'passed',
+        badge: '已完成'
+      },
+      {
+        title: '出库完成',
+        desc: '实物已交接,单据归档',
+        time: '08-07 14:22',
+        tone: 'done',
+        actor: '张三',
+        icon: 'checked',
+        badge: '已完成'
+      }
+    ]
+  }
+]
+
+export function getList() {
+  return listData.slice()
+}
+
+export function getById(id) {
+  const n = Number(id)
+  return listData.find((r) => r.id === n) || listData[0]
+}
+
+function rowsByTab(idx) {
+  if (idx === 1) return listData.filter((r) => r.status === '进行中')
+  if (idx === 2) return listData.filter((r) => r.status === '待处理')
+  if (idx === 3) return listData.filter((r) => r.status === '已完成')
+  return listData
+}
+
+export function tabCount(idx) {
+  return rowsByTab(idx).length
+}
+
+export function filterByTab(tabIndex, keyword) {
+  const kw = (keyword || '').trim()
+  let rows = rowsByTab(tabIndex)
+  if (!kw) return rows
+  return rows.filter(
+    (r) => r.title.includes(kw) || r.code.includes(kw) || r.owner.includes(kw)
+  )
+}
+
+export const LIST_TABS = [
+  { key: 'all', label: '全部' },
+  { key: 'doing', label: '进行中' },
+  { key: 'pending', label: '待处理' },
+  { key: 'done', label: '已完成' }
+]

+ 19 - 0
mobile-h5/utils/menu-mock.js

@@ -0,0 +1,19 @@
+/** 模拟「菜单管理 → 移动端菜单」授权结果 */
+export const ALL_MOBILE_MENUS = [
+  { id: 'm1', title: '待办审批', icon: 'todo-list-o', path: '/pages/demo/list' },
+  { id: 'm2', title: '采集协议', icon: 'orders-o', path: '/pages/demo/list' },
+  { id: 'm3', title: '缴费确认', icon: 'balance-list-o', path: '/pages/demo/list' },
+  { id: 'm4', title: '样本接收', icon: 'logistics', path: '/pages/demo/list' },
+  { id: 'm5', title: '质检工单', icon: 'certificate', path: '/pages/demo/list' },
+  { id: 'm6', title: '生产任务', icon: 'cluster-o', path: '/pages/demo/list' },
+  { id: 'm7', title: '库位出库', icon: 'send-gift-o', path: '/pages/demo/list' },
+  { id: 'm8', title: '组件示例', icon: 'apps-o', path: '/pages/demo/comps' }
+]
+
+/** @param {string[]} grantedIds */
+export function filterMenusByPermission(grantedIds) {
+  const set = new Set(grantedIds || [])
+  return ALL_MOBILE_MENUS.filter((m) => set.has(m.id))
+}
+
+export const DEFAULT_GRANTED_IDS = ['m1', 'm2', 'm3', 'm4', 'm5', 'm7', 'm8']