Bladeren bron

feat(web): Phase 2 引入企业级复用组件层(StatCard/StatusTag/PageHeader/SectionTitle)

- 新增 4 个薄复用组件并全局注册(main.js),页面无需单独 import 即可使用
  * StatCard   KPI 统计卡,兼容旧数据 {color,icon,value,label},旧 color 名自动映射(purple->primary)
  * StatusTag  状态标签,支持 options 映射式与 text+type 简单式两种用法
  * PageHeader 页头,标题+副标题+右侧操作区 slot
  * SectionTitle 区块小标题
- 组件 class 统一 adm- 前缀:Vue2 scoped 样式会命中子组件根元素,
  而 8 个页面各自定义了 .stat-card,不加前缀会被父页样式污染
- 样板迁移 admin/MembershipCenter.vue:改用 PageHeader + StatCard,数据零改动
- 后续铺开优先级:el-tag 129 页 > admin-page-header 47 页 > stat-card 19 页
liaoxg 1 week geleden
bovenliggende
commit
8a07de7b49

+ 125 - 0
cfc-web/src/components/PageHeader.vue

@@ -0,0 +1,125 @@
+<template>
+  <div class="adm-page-header">
+    <div class="adm-page-header__main">
+      <div class="adm-page-header__title-row">
+        <el-button
+          v-if="back"
+          class="adm-page-header__back"
+          type="text"
+          icon="el-icon-arrow-left"
+          @click="handleBack"
+        >返回</el-button>
+        <h2 class="adm-page-header__title">{{ title }}</h2>
+        <span v-if="count !== null && count !== undefined" class="adm-page-header__count">共 {{ count }} 条</span>
+      </div>
+      <p v-if="subtitle" class="adm-page-header__subtitle">{{ subtitle }}</p>
+    </div>
+    <div class="adm-page-header__extra">
+      <slot />
+    </div>
+  </div>
+</template>
+
+<script>
+/**
+ * PageHeader —— 统一页头
+ *
+ * 设计目的:后台 47 个页面各自手写
+ *   <div class="admin-page-header"><span class="admin-page-title">标题</span><el-button>...</el-button></div>
+ * 结构/间距/字号不统一。本组件把「标题 + 副标题 + 右侧操作区」收敛为一处。
+ *
+ * 用法:
+ *   <PageHeader title="会员管理" subtitle="等级与权益配置">
+ *     <el-button size="small" icon="el-icon-refresh">刷新</el-button>
+ *   </PageHeader>
+ *
+ * 也可作为卡片 header 内容:
+ *   <el-card><div slot="header"><PageHeader title="编辑商品" /></div> ... </el-card>
+ */
+export default {
+  name: 'PageHeader',
+  props: {
+    /** 页面标题 */
+    title: { type: String, required: true },
+    /** 副标题/说明 */
+    subtitle: { type: String, default: '' },
+    /** 列表总数,传入则在标题后展示「共 N 条」 */
+    count: { type: [Number, String], default: null },
+    /** 是否展示返回按钮 */
+    back: { type: Boolean, default: false }
+  },
+  methods: {
+    handleBack() {
+      // 默认回退一级;页面可通过 @back 覆盖(本组件派发 back 事件)
+      this.$emit('back')
+      if (!this.$listeners || !this.$listeners.back) {
+        this.$router && this.$router.back()
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.adm-page-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 16px;
+  flex-wrap: wrap;
+}
+
+.adm-page-header__main {
+  min-width: 0;
+  flex: 1 1 auto;
+}
+
+.adm-page-header__title-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+}
+
+.adm-page-header__back {
+  padding: 0;
+  margin-right: -2px;
+}
+
+.adm-page-header__title {
+  margin: 0;
+  font-size: 20px;
+  font-weight: 600;
+  line-height: 28px;
+  color: #262626;
+}
+
+.adm-page-header__count {
+  font-size: 13px;
+  color: #8c8c8c;
+  font-variant-numeric: tabular-nums;
+}
+
+.adm-page-header__subtitle {
+  margin: 4px 0 0;
+  font-size: 13px;
+  line-height: 20px;
+  color: #8c8c8c;
+}
+
+.adm-page-header__extra {
+  flex: 0 0 auto;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+/* 窄屏下操作区换行,避免挤压标题 */
+@media (max-width: 768px) {
+  .adm-page-header__extra {
+    width: 100%;
+    flex-wrap: wrap;
+  }
+}
+</style>

+ 73 - 0
cfc-web/src/components/SectionTitle.vue

@@ -0,0 +1,73 @@
+<template>
+  <div class="adm-section-title" :class="{ 'is-plain': plain }">
+    <span class="adm-section-title__bar"></span>
+    <span class="adm-section-title__text">{{ title }}</span>
+    <span v-if="desc" class="adm-section-title__desc">{{ desc }}</span>
+    <span class="adm-section-title__extra">
+      <slot />
+    </span>
+  </div>
+</template>
+
+<script>
+/**
+ * SectionTitle —— 区块小标题
+ *
+ * 设计目的:卡片内「基础信息 / 高级配置 / 操作日志」等分区,
+ * 目前各页用 el-divider、裸 div 或 h3 混写,缩进与字号不统一。
+ *
+ * 用法:
+ *   <SectionTitle title="基础信息" desc="必填项" />
+ *   <SectionTitle title="操作日志"><el-button size="mini">导出</el-button></SectionTitle>
+ */
+export default {
+  name: 'SectionTitle',
+  props: {
+    /** 区块标题 */
+    title: { type: String, required: true },
+    /** 右侧灰色补充说明 */
+    desc: { type: String, default: '' },
+    /** 无装饰竖线的纯文本样式 */
+    plain: { type: Boolean, default: false }
+  }
+}
+</script>
+
+<style scoped>
+.adm-section-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 4px 0 12px;
+}
+
+.adm-section-title__bar {
+  flex: 0 0 auto;
+  width: 3px;
+  height: 14px;
+  border-radius: 2px;
+  background: #1677ff;
+}
+.adm-section-title.is-plain .adm-section-title__bar {
+  display: none;
+}
+
+.adm-section-title__text {
+  font-size: 14px;
+  font-weight: 600;
+  line-height: 22px;
+  color: #262626;
+}
+
+.adm-section-title__desc {
+  font-size: 12px;
+  color: #8c8c8c;
+}
+
+.adm-section-title__extra {
+  margin-left: auto;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+</style>

+ 182 - 0
cfc-web/src/components/StatCard.vue

@@ -0,0 +1,182 @@
+<template>
+  <div
+    class="adm-stat-card"
+    :class="['adm-stat-card--' + resolvedColor, { 'is-clickable': clickable }]"
+    @click="handleClick"
+  >
+    <div v-if="icon" class="stat-card__icon">
+      <i :class="icon"></i>
+    </div>
+    <div class="stat-card__body">
+      <div class="stat-card__value">
+        <span v-if="prefix" class="stat-card__affix">{{ prefix }}</span>
+        <span class="stat-card__num">{{ displayValue }}</span>
+        <span v-if="suffix" class="stat-card__affix">{{ suffix }}</span>
+      </div>
+      <div class="stat-card__label">{{ label }}</div>
+    </div>
+  </div>
+</template>
+
+<script>
+/**
+ * StatCard —— 企业级 KPI 统计卡
+ *
+ * 设计目的:统一后台 19 个页面各自手写的 .stat-card / .stat-icon-wrap / .stat-value / .stat-label,
+ * 消除内联色值与圆角/阴影漂移,收敛到 Ant Design 5 语义色令牌。
+ *
+ * 兼容旧数据形态 { color, icon, value, label }:
+ *   旧写法  <el-card shadow="hover" class="stat-card" :class="'stat-' + card.color"> ... </el-card>
+ *   新写法  <StatCard :label="c.label" :value="c.value" :icon="c.icon" :color="c.color" />
+ * 旧 color 名(blue/purple/green/orange/red/cyan 等)由 COLOR_MAP 自动映射,无需改数据。
+ */
+const COLOR_MAP = {
+  primary: 'primary',
+  blue: 'primary',
+  purple: 'primary', // 旧品牌紫统一收敛为品牌蓝
+  success: 'success',
+  green: 'success',
+  warning: 'warning',
+  orange: 'warning',
+  amber: 'warning',
+  yellow: 'warning',
+  danger: 'danger',
+  red: 'danger',
+  cyan: 'cyan',
+  teal: 'cyan',
+  info: 'info',
+  gray: 'info',
+  grey: 'info',
+  default: 'info'
+}
+
+export default {
+  name: 'StatCard',
+  props: {
+    /** 指标名称 */
+    label: { type: String, default: '' },
+    /** 指标数值 */
+    value: { type: [String, Number], default: '' },
+    /** Element UI 图标 class,如 el-icon-user */
+    icon: { type: String, default: '' },
+    /** 语义色:primary/success/warning/danger/cyan/info(旧名自动映射) */
+    color: { type: String, default: 'primary' },
+    /** 数值前缀,如 ¥ */
+    prefix: { type: String, default: '' },
+    /** 数值后缀,如 元 / % */
+    suffix: { type: String, default: '' },
+    /** 数字是否按千分位格式化(仅对 number 生效) */
+    thousand: { type: Boolean, default: false },
+    /** 是否可点击(开启后带 hover 反馈并派发 click) */
+    clickable: { type: Boolean, default: false }
+  },
+  computed: {
+    resolvedColor() {
+      return COLOR_MAP[String(this.color || 'primary').toLowerCase()] || 'primary'
+    },
+    displayValue() {
+      const v = this.value
+      if (v === null || v === undefined || v === '') return '-'
+      if (this.thousand && typeof v === 'number' && isFinite(v)) {
+        return v.toLocaleString('en-US')
+      }
+      return v
+    }
+  },
+  methods: {
+    handleClick() {
+      if (this.clickable) this.$emit('click')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.adm-stat-card {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 16px;
+  background: #fff;
+  border: 1px solid #f0f0f0;
+  border-radius: 8px;
+  height: 100%;
+  box-sizing: border-box;
+  transition: border-color 0.2s ease;
+}
+.adm-stat-card.is-clickable {
+  cursor: pointer;
+}
+.adm-stat-card.is-clickable:hover {
+  border-color: #1677ff;
+}
+
+.adm-stat-card__icon {
+  flex: 0 0 auto;
+  width: 44px;
+  height: 44px;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 20px;
+}
+
+.adm-stat-card__body {
+  flex: 1 1 auto;
+  min-width: 0;
+}
+
+.adm-stat-card__value {
+  font-size: 24px;
+  font-weight: 600;
+  line-height: 1.2;
+  color: #262626;
+  font-variant-numeric: tabular-nums;
+  font-feature-settings: 'tnum';
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.adm-stat-card__affix {
+  font-size: 14px;
+  font-weight: 400;
+  color: #8c8c8c;
+  margin: 0 2px;
+}
+
+.adm-stat-card__label {
+  font-size: 13px;
+  color: #8c8c8c;
+  margin-top: 2px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+/* ---- 语义色(Ant Design 5 浅底 + 深字) ---- */
+.adm-stat-card--primary .adm-stat-card__icon {
+  background: #e6f4ff;
+  color: #1677ff;
+}
+.adm-stat-card--success .adm-stat-card__icon {
+  background: #f6ffed;
+  color: #389e0d;
+}
+.adm-stat-card--warning .adm-stat-card__icon {
+  background: #fffbe6;
+  color: #d48806;
+}
+.adm-stat-card--danger .adm-stat-card__icon {
+  background: #fff1f0;
+  color: #cf1322;
+}
+.adm-stat-card--cyan .adm-stat-card__icon {
+  background: #e6fffb;
+  color: #08979c;
+}
+.adm-stat-card--info .adm-stat-card__icon {
+  background: #f5f5f5;
+  color: #595959;
+}
+</style>

+ 80 - 0
cfc-web/src/components/StatusTag.vue

@@ -0,0 +1,80 @@
+<template>
+  <el-tag
+    :type="elTagType"
+    :size="size"
+    :effect="effect"
+    :class="{ 'adm-tag--primary': resolvedType === 'primary' }"
+    disable-transitions
+  >{{ resolvedText }}</el-tag>
+</template>
+
+<script>
+/**
+ * StatusTag —— 统一状态标签
+ *
+ * 设计目的:后台 129 个页面直接使用 <el-tag> 且状态→文案/颜色的映射散落各处
+ * (有 levelTagType() 函数式,也有 row.x ? 'success' : 'info' 内联三元式),
+ * 导致同一状态在不同页面颜色/文案不一致。本组件把映射收敛到 options。
+ *
+ * 用法一(映射式,推荐):
+ *   const STATUS_OPTIONS = [
+ *     { value: 0, label: '待审核', type: 'warning' },
+ *     { value: 1, label: '已通过', type: 'success' }
+ *   ]
+ *   <StatusTag :value="row.status" :options="STATUS_OPTIONS" />
+ *
+ * 用法二(简单式,等价原 el-tag):
+ *   <StatusTag :type="row.enabled ? 'success' : 'info'" :text="row.enabled ? '是' : '否'" />
+ *
+ * type 取值:primary / success / info / warning / danger
+ */
+export default {
+  name: 'StatusTag',
+  props: {
+    /** 当前状态值(映射式必填) */
+    value: { type: [String, Number, Boolean], default: '' },
+    /** 状态映射表:[{ value, label, type }] */
+    options: { type: Array, default: null },
+    /** 直接指定文案(优先于 options 的 label) */
+    text: { type: String, default: '' },
+    /** 直接指定类型:primary/success/info/warning/danger */
+    type: { type: String, default: '' },
+    /** 尺寸:medium / small / mini */
+    size: { type: String, default: 'small' },
+    /** 主题:light / dark / plain */
+    effect: { type: String, default: 'light' },
+    /** 命中不到映射时的兜底文案 */
+    fallback: { type: String, default: '-' }
+  },
+  computed: {
+    matched() {
+      if (!this.options || !this.options.length) return null
+      return this.options.find(item => item.value === this.value) || null
+    },
+    resolvedText() {
+      if (this.text) return this.text
+      if (this.matched && this.matched.label !== undefined) return this.matched.label
+      if (this.value === '' || this.value === null || this.value === undefined) return this.fallback
+      return this.value
+    },
+    resolvedType() {
+      if (this.type) return this.type
+      if (this.matched && this.matched.type) return this.matched.type
+      return 'info'
+    },
+    // Element UI 的 el-tag 默认态即品牌蓝,primary 交由自定义 class 精确控制配色
+    elTagType() {
+      return this.resolvedType === 'primary' ? 'info' : this.resolvedType
+    }
+  }
+}
+</script>
+
+<style scoped>
+/* 品牌蓝状态标签:与 admin-tokens 的 Ant Design 5 令牌保持一致 */
+.adm-tag--primary {
+  background: #e6f4ff;
+  border-color: #91caff;
+  color: #1677ff;
+}
+</style>

+ 12 - 0
cfc-web/src/main.js

@@ -18,6 +18,12 @@ import './styles/admin-tokens.css'
 import './assets/css/sort.css'
 import { formatPrice, formatPriceWithSymbol, formatRate, formatScore, formatDateTime, formatDate } from './utils/format'
 
+// 企业级复用组件(Phase 2):统一 KPI 卡 / 状态标签 / 页头 / 区块标题
+import StatCard from './components/StatCard.vue'
+import StatusTag from './components/StatusTag.vue'
+import PageHeader from './components/PageHeader.vue'
+import SectionTitle from './components/SectionTitle.vue'
+
 Vue.use(Button)
 Vue.use(Table)
 Vue.use(TableColumn)
@@ -87,6 +93,12 @@ Vue.prototype.formatScore = formatScore
 Vue.prototype.formatTime = formatDateTime
 Vue.prototype.formatDate = formatDate
 
+// 注册企业级复用组件:页面无需单独 import 即可直接使用
+Vue.component('StatCard', StatCard)
+Vue.component('StatusTag', StatusTag)
+Vue.component('PageHeader', PageHeader)
+Vue.component('SectionTitle', SectionTitle)
+
 // Fix Element UI 2.15.x el-submenu removeTabindex null error
 // https://github.com/ElemeFE/element/issues/21965
 // Also fixes: v-if 切换菜单项后 el-submenu 残留 DOM 遮罩导致无法点击的问题

+ 3 - 10
cfc-web/src/views/admin/MembershipCenter.vue

@@ -2,21 +2,14 @@
   <div class="membership-center admin-page">
 
     <!-- 页面头部 -->
-    <div class="admin-page-header">
-      <span class="admin-page-title">会员管理</span>
+    <PageHeader title="会员管理">
       <el-button size="small" icon="el-icon-refresh" @click="refreshAll" :loading="overviewLoading">刷新</el-button>
-    </div>
+    </PageHeader>
 
     <!-- 统计概览 -->
     <el-row :gutter="16" style="margin-bottom:16px;">
       <el-col :xs="12" :sm="8" :md="4" v-for="(card, idx) in statCards" :key="idx">
-        <el-card shadow="hover" class="stat-card" :class="'stat-' + card.color">
-          <div class="stat-icon-wrap"><i :class="card.icon"></i></div>
-          <div class="stat-body">
-            <div class="stat-value">{{ card.value }}</div>
-            <div class="stat-label">{{ card.label }}</div>
-          </div>
-        </el-card>
+        <StatCard :label="card.label" :value="card.value" :icon="card.icon" :color="card.color" />
       </el-col>
     </el-row>