Kaynağa Gözat

feat(admin-mp): sync P0-P1 features from admin-web

P0 - Core business features:
- Marketing: activity management (list/detail/create/edit)
- Marketing: coupon management (list/detail/create/distribute)
- Marketing: user coupon query, timed discount management
- Orders: independent refund management (list/review approve-reject)

P1 - Operational efficiency:
- Products: master product library view
- Distribution: referral records, commission records
- Statistics: category sales, product ranking, device/shop sales, profit, repurchase
- Announcement: create/edit/publish form

Infrastructure:
- 6 new API modules (activity, coupon, userCoupon, timedDiscount, refund, masterProduct)
- 13 new enum/constant groups added to constants.ts
- 20 new pages registered in pages.json
- Navigation: workbench marketing card, expanded my-menu entries

Bugfix: workbench 'low stock' link corrected from non-existent /pages/inventory/list to /pages/inventory/warning
skyline 1 hafta önce
ebeveyn
işleme
ed87b76952
30 değiştirilmiş dosya ile 2120 ekleme ve 3 silme
  1. 18 0
      haha-admin-mp/src/api/activity.ts
  2. 13 0
      haha-admin-mp/src/api/coupon.ts
  3. 6 0
      haha-admin-mp/src/api/index.ts
  4. 7 0
      haha-admin-mp/src/api/masterProduct.ts
  5. 9 0
      haha-admin-mp/src/api/refund.ts
  6. 14 0
      haha-admin-mp/src/api/timedDiscount.ts
  7. 6 0
      haha-admin-mp/src/api/userCoupon.ts
  8. 175 1
      haha-admin-mp/src/pages.json
  9. 107 0
      haha-admin-mp/src/pages/announcement/form.vue
  10. 66 0
      haha-admin-mp/src/pages/distribution/commission.vue
  11. 62 0
      haha-admin-mp/src/pages/distribution/referral.vue
  12. 36 1
      haha-admin-mp/src/pages/index/index.vue
  13. 174 0
      haha-admin-mp/src/pages/marketing/activity-detail.vue
  14. 163 0
      haha-admin-mp/src/pages/marketing/activity-list.vue
  15. 137 0
      haha-admin-mp/src/pages/marketing/coupon-detail.vue
  16. 118 0
      haha-admin-mp/src/pages/marketing/coupon-list.vue
  17. 85 0
      haha-admin-mp/src/pages/marketing/timed-discount-detail.vue
  18. 63 0
      haha-admin-mp/src/pages/marketing/timed-discount-list.vue
  19. 63 0
      haha-admin-mp/src/pages/marketing/user-coupon-list.vue
  20. 69 0
      haha-admin-mp/src/pages/my/my.vue
  21. 60 0
      haha-admin-mp/src/pages/orders/refund-detail.vue
  22. 109 0
      haha-admin-mp/src/pages/orders/refund-list.vue
  23. 51 0
      haha-admin-mp/src/pages/products/master-library.vue
  24. 51 0
      haha-admin-mp/src/pages/statistics/category.vue
  25. 67 0
      haha-admin-mp/src/pages/statistics/device-sales.vue
  26. 65 0
      haha-admin-mp/src/pages/statistics/product-rank.vue
  27. 65 0
      haha-admin-mp/src/pages/statistics/profit.vue
  28. 42 0
      haha-admin-mp/src/pages/statistics/repurchase.vue
  29. 68 0
      haha-admin-mp/src/pages/statistics/shop-sales.vue
  30. 151 1
      haha-admin-mp/src/utils/constants.ts

+ 18 - 0
haha-admin-mp/src/api/activity.ts

@@ -0,0 +1,18 @@
+/**
+ * 营销活动管理 API
+ */
+import { get, post, put, del } from '@/utils/request';
+
+export interface ActivityQueryParams { page?: number; pageSize?: number; activityName?: string; activityType?: number; status?: number; }
+export interface ActivityListResponse { list: any[]; total: number; pageSize: number; currentPage: number; }
+
+export function getActivityList(params: ActivityQueryParams): Promise<ActivityListResponse> { return get('/marketing/activity/list', params); }
+export function getActivityById(id: number): Promise<any> { return get('/marketing/activity/' + id); }
+export function createActivity(data: any): Promise<any> { return post('/marketing/activity', data); }
+export function updateActivity(id: number, data: any): Promise<any> { return put('/marketing/activity/' + id, data); }
+export function publishActivity(id: number): Promise<any> { return put('/marketing/activity/' + id + '/publish'); }
+export function pauseActivity(id: number): Promise<any> { return put('/marketing/activity/' + id + '/pause'); }
+export function resumeActivity(id: number): Promise<any> { return put('/marketing/activity/' + id + '/resume'); }
+export function deleteActivity(id: number): Promise<any> { return del('/marketing/activity/' + id); }
+export function getActivityStatistics(id: number): Promise<any> { return get('/marketing/activity/' + id + '/statistics'); }
+export function getOngoingActivities(): Promise<any[]> { return get('/marketing/activity/ongoing'); }

+ 13 - 0
haha-admin-mp/src/api/coupon.ts

@@ -0,0 +1,13 @@
+import { get, post, put, del } from '@/utils/request';
+
+export interface CouponQueryParams { page?: number; pageSize?: number; name?: string; type?: number; status?: number; }
+export interface CouponListResponse { list: any[]; total: number; pageSize: number; currentPage: number; }
+
+export function getCouponList(params: CouponQueryParams): Promise<CouponListResponse> { return get('/marketing/coupon/list', params); }
+export function getCouponById(id: number): Promise<any> { return get('/marketing/coupon/' + id); }
+export function createCoupon(data: any): Promise<any> { return post('/marketing/coupon', data); }
+export function updateCouponStatus(id: number, status: number): Promise<any> { return put('/marketing/coupon/' + id + '/status?status=' + status); }
+export function deleteCoupon(id: number): Promise<any> { return del('/marketing/coupon/' + id); }
+export function distributeCoupon(data: { templateId: number; targetType: number; userIds?: number[] }): Promise<any> { return post('/marketing/coupon/distribute', data); }
+export function getCouponStatistics(id: number): Promise<any> { return get('/marketing/coupon/' + id + '/statistics'); }
+export function getAvailableCoupons(): Promise<any[]> { return get('/marketing/coupon/available'); }

+ 6 - 0
haha-admin-mp/src/api/index.ts

@@ -3,6 +3,12 @@
  */
 
 export * from './auth';
+export * from './activity';
+export * from './coupon';
+export * from './userCoupon';
+export * from './timedDiscount';
+export * from './refund';
+export * from './masterProduct';
 export * from './dashboard';
 export * from './order';
 export * from './device';

+ 7 - 0
haha-admin-mp/src/api/masterProduct.ts

@@ -0,0 +1,7 @@
+import { get, post } from '@/utils/request';
+
+export interface MasterProductQueryParams { page?: number; pageSize?: number; barcode?: string; code?: string; productName?: string; deviceAdapt?: string; }
+export interface MasterProductListResponse { list: any[]; total: number; pageSize: number; currentPage: number; }
+
+export function getMasterProductList(params: MasterProductQueryParams): Promise<MasterProductListResponse> { return get('/products/master/list', params); }
+export function addToMerchantProducts(data: { codeList: string }): Promise<any> { return post('/products/master/add-to-merchant', data); }

+ 9 - 0
haha-admin-mp/src/api/refund.ts

@@ -0,0 +1,9 @@
+import { get, post } from '@/utils/request';
+
+export interface RefundQueryParams { page?: number; pageSize?: number; orderNo?: string; status?: number; startDate?: string; endDate?: string; }
+export interface RefundListResponse { list: any[]; total: number; pageSize: number; currentPage: number; }
+
+export function getRefundList(params: RefundQueryParams): Promise<RefundListResponse> { return get('/refund/list', params); }
+export function getRefundDetail(id: number): Promise<any> { return get('/refund/' + id); }
+export function reviewRefund(id: number, data: { approved: boolean; remark?: string }): Promise<any> { return post('/refund/' + id + '/review', data); }
+export function refundOrder(orderId: string, reason: string): Promise<void> { return post('/order/' + orderId + '/refund', { reason }); }

+ 14 - 0
haha-admin-mp/src/api/timedDiscount.ts

@@ -0,0 +1,14 @@
+import { get, post, put, del } from '@/utils/request';
+
+export interface TimedDiscountQueryParams { page?: number; pageSize?: number; activityName?: string; status?: number; discountType?: number; }
+export interface TimedDiscountListResponse { list: any[]; total: number; pageSize: number; currentPage: number; }
+
+export function getTimedDiscountList(params: TimedDiscountQueryParams): Promise<TimedDiscountListResponse> { return get('/timed-discount/list', params); }
+export function getTimedDiscountById(id: number): Promise<any> { return get('/timed-discount/' + id); }
+export function createTimedDiscount(data: any): Promise<any> { return post('/timed-discount', data); }
+export function updateTimedDiscount(id: number, data: any): Promise<any> { return put('/timed-discount/' + id, data); }
+export function updateTimedDiscountStatus(id: number, status: number): Promise<any> { return put('/timed-discount/' + id + '/status', { status }); }
+export function deleteTimedDiscount(id: number): Promise<any> { return del('/timed-discount/' + id); }
+export function executeTimedDiscount(id: number): Promise<any> { return post('/timed-discount/' + id + '/execute'); }
+export function getTimedDiscountRecords(params: any): Promise<TimedDiscountListResponse> { return get('/timed-discount/records', params); }
+export function getTimedDiscountStatistics(id: number): Promise<any> { return get('/timed-discount/' + id + '/statistics'); }

+ 6 - 0
haha-admin-mp/src/api/userCoupon.ts

@@ -0,0 +1,6 @@
+import { get } from '@/utils/request';
+
+export interface UserCouponQueryParams { page?: number; pageSize?: number; userId?: number; couponCode?: string; status?: number; }
+export interface UserCouponListResponse { list: any[]; total: number; pageSize: number; currentPage: number; }
+
+export function getUserCouponList(params: UserCouponQueryParams): Promise<UserCouponListResponse> { return get('/marketing/coupon/user/list', params); }

+ 175 - 1
haha-admin-mp/src/pages.json

@@ -36,6 +36,24 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/orders/refund-list",
+      "style": {
+        "navigationBarTitleText": "退款管理",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/orders/refund-detail",
+      "style": {
+        "navigationBarTitleText": "退款详情",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
     {
       "path": "pages/device/list",
       "style": {
@@ -117,6 +135,76 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/products/master-library",
+      "style": {
+        "navigationBarTitleText": "商品总库",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/marketing/activity-list",
+      "style": {
+        "navigationBarTitleText": "活动管理",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/marketing/activity-detail",
+      "style": {
+        "navigationBarTitleText": "活动详情",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/marketing/coupon-list",
+      "style": {
+        "navigationBarTitleText": "优惠券管理",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/marketing/coupon-detail",
+      "style": {
+        "navigationBarTitleText": "优惠券详情",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/marketing/user-coupon-list",
+      "style": {
+        "navigationBarTitleText": "用户优惠券",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },{"path": "pages/marketing/timed-discount-list",
+      "style": {
+        "navigationBarTitleText": "定时折扣",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/marketing/timed-discount-detail",
+      "style": {
+        "navigationBarTitleText": "定时折扣详情",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
     {
       "path": "pages/inventory/query",
       "style": {
@@ -162,6 +250,60 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/statistics/category",
+      "style": {
+        "navigationBarTitleText": "品类销售",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/statistics/product-rank",
+      "style": {
+        "navigationBarTitleText": "商品排行",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/statistics/device-sales",
+      "style": {
+        "navigationBarTitleText": "设备销售",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/statistics/shop-sales",
+      "style": {
+        "navigationBarTitleText": "门店销售",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/statistics/profit",
+      "style": {
+        "navigationBarTitleText": "利润报表",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/statistics/repurchase",
+      "style": {
+        "navigationBarTitleText": "复购统计",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
     {
       "path": "pages/my/my",
       "style": {
@@ -216,10 +358,28 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/distribution/referral",
+      "style": {
+        "navigationBarTitleText": "推荐记录",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/distribution/commission",
+      "style": {
+        "navigationBarTitleText": "佣金记录",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
     {
       "path": "pages/distribution/withdrawal",
       "style": {
-        "navigationBarTitleText": "申请提现",
+        "navigationBarTitleText": "提现管理",
         "navigationBarBackgroundColor": "#ffffff",
         "navigationBarTextStyle": "black",
         "navigationStyle": "custom"
@@ -270,6 +430,15 @@
       },
       "path": "pages/announcement/list"
     },
+    {
+      "style": {
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationBarTitleText": "发布公告",
+        "navigationStyle": "custom"
+      },
+      "path": "pages/announcement/form"
+    },
     {
       "style": {
         "navigationBarBackgroundColor": "#ffffff",
@@ -358,3 +527,8 @@
     ]
   }
 }
+
+
+
+
+

+ 107 - 0
haha-admin-mp/src/pages/announcement/form.vue

@@ -0,0 +1,107 @@
+<template>
+  <view class="page">
+    <NavBar :title="isEdit ? '编辑公告' : '发布公告'" :showBack="true" />
+    <scroll-view class="detail-scroll" scroll-y>
+      <view class="section">
+        <text class="section-title">公告内容</text>
+        <view class="form-item">
+          <text class="form-label">标题</text>
+          <input class="form-input" v-model="form.title" placeholder="请输入公告标题" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">类型</text>
+          <picker :range="typeOptions" range-key="label" :value="typeIndex" @change="onTypeChange">
+            <view class="form-picker">{{ typeOptions[typeIndex]?.label || '请选择' }}</view>
+          </picker>
+        </view>
+        <view class="form-item">
+          <text class="form-label">置顶</text>
+          <switch :checked="form.isTop === 1" @change="form.isTop = $event.detail.value ? 1 : 0" color="#FFC107" />
+        </view>
+        <view class="form-area">
+          <text class="form-label" style="margin-bottom: 16rpx">内容</text>
+          <textarea class="form-textarea" v-model="form.content" placeholder="请输入公告内容" />
+        </view>
+      </view>
+    </scroll-view>
+    <view class="bottom-actions">
+      <view class="action-row">
+        <view class="save-btn draft" @click="handleSave(true)"><text>保存草稿</text></view>
+        <view class="save-btn primary" @click="handlePublish"><text>立即发布</text></view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app';
+import NavBar from '@/components/NavBar.vue';
+import { AnnouncementTypeText } from '@/utils/constants';
+import { showToast } from '@/utils/common';
+import { post, put } from '@/utils/request';
+
+const typeOptions = [
+  { label: AnnouncementTypeText[1], value: 1 },
+  { label: AnnouncementTypeText[2], value: 2 },
+  { label: AnnouncementTypeText[3], value: 3 }
+];
+
+const isEdit = ref(false);
+const typeIndex = ref(0);
+const form = ref<any>({ title: '', type: 1, content: '', isTop: 0, status: 0 });
+
+onLoad((options?: Record<string, string>) => {
+  const id = options?.id ? Number(options.id) : 0;
+  if (id > 0) {
+    isEdit.value = true;
+    loadDetail(id);
+  }
+});
+
+const loadDetail = async (id: number) => {
+  try {
+    const res = await (await import('@/utils/request')).get('/announcement/' + id);
+    form.value = res;
+    typeIndex.value = typeOptions.findIndex(t => t.value === res.type);
+    if (typeIndex.value < 0) typeIndex.value = 0;
+  } catch { showToast('加载失败'); uni.navigateBack(); }
+};
+
+const onTypeChange = (e: any) => { typeIndex.value = e.detail.value; form.value.type = typeOptions[typeIndex.value].value; };
+
+const handleSave = async (isDraft: boolean) => {
+  form.value.status = isDraft ? 0 : 1;
+  try {
+    if (isEdit.value && form.value.id) {
+      await put('/announcement/' + form.value.id, form.value);
+    } else {
+      await post('/announcement', form.value);
+    }
+    showToast(isDraft ? '已保存草稿' : '已发布', 'success');
+    setTimeout(() => uni.navigateBack(), 1500);
+  } catch { showToast('操作失败'); }
+};
+
+const handlePublish = () => handleSave(false);
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.detail-scroll { flex: 1; height: 0; }
+.section { background: $bg-color-card; margin-bottom: $spacing-2; padding: $spacing-3 $spacing-4; }
+.section-title { display: block; font-size: 28rpx; font-weight: 600; color: $text-color-primary; margin-bottom: $spacing-3; padding-bottom: $spacing-2; border-bottom: 1rpx solid $border-color-light; }
+.form-item { display: flex; align-items: center; justify-content: space-between; padding: $spacing-2 0; border-bottom: 1rpx solid $border-color-light; }
+.form-label { font-size: 28rpx; color: $text-color-secondary; flex-shrink: 0; width: 120rpx; }
+.form-input { flex: 1; text-align: right; font-size: 28rpx; color: $text-color-primary; }
+.form-picker { flex: 1; text-align: right; font-size: 28rpx; color: $text-color-muted; }
+.form-area { padding: $spacing-2 0; }
+.form-textarea { width: 100%; height: 400rpx; background: $bg-color-page; border-radius: $radius-base; padding: 20rpx; font-size: 28rpx; box-sizing: border-box; color: $text-color-primary; }
+.bottom-actions { padding: $spacing-3 $spacing-4; padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); background: $bg-color-card; border-top: 1rpx solid $border-color-light; flex-shrink: 0; }
+.action-row { display: flex; gap: $spacing-2; }
+.save-btn { flex: 1; padding: 28rpx 0; border-radius: $radius-xl; display: flex; align-items: center; justify-content: center; }
+.save-btn text { font-size: $font-size-lg; font-weight: 600; }
+.save-btn.draft { background: $bg-color-page; } .save-btn.draft text { color: $text-color-secondary; }
+.save-btn.primary { background: $primary-color; } .save-btn.primary text { color: $text-color-primary; }
+</style>
+

+ 66 - 0
haha-admin-mp/src/pages/distribution/commission.vue

@@ -0,0 +1,66 @@
+<template>
+  <view class="page">
+    <NavBar title="佣金记录" :showBack="true" />
+    <scroll-view class="list-scroll" scroll-y @scrolltolower="loadMore">
+      <view class="card-set">
+        <view class="card" v-for="item in list" :key="item.id">
+          <view class="card-head">
+            <view class="card-left">
+              <text class="card-name">{{ item.distributorName || '分销员' }}</text>
+              <text class="card-source">来源: {{ item.sourceOrderNo || '-' }}</text>
+              <text class="card-time">{{ item.createTime }}</text>
+            </view>
+            <view class="card-amount">{{ formatMoney(item.amount || 0) }}</view>
+            <view class="card-status" :class="statusClass(item.status)">
+              <text>{{ item.status === 1 ? '已结算' : '待结算' }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+      <view class="load-tip" v-if="loading"><view class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view></view>
+      <view class="end-tip" v-if="!hasMore && list.length > 0"><text>没有更多了</text></view>
+      <view class="empty-state" v-if="!loading && list.length === 0"><text class="empty-text">暂无佣金记录</text></view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { get } from '@/utils/request';
+import { formatMoney } from '@/utils/common';
+import { DEFAULT_PAGE_SIZE } from '@/utils/constants';
+const list = ref<any[]>([]); const loading = ref(false); const hasMore = ref(true); const page = ref(1);
+const statusClass = (s: number) => s === 1 ? 'settled' : 'pending';
+const loadData = async () => {
+  loading.value = true;
+  try { const res = await get('/distribution/commission/list', { page: page.value, pageSize: DEFAULT_PAGE_SIZE }); if (page.value === 1) list.value = res.list || []; else list.value = [...list.value, ...(res.list || [])]; hasMore.value = list.value.length < (res.total || 0); } catch { }
+  loading.value = false;
+};
+const loadMore = () => { if (loading.value || !hasMore.value) return; page.value++; loadData(); };
+onMounted(() => loadData());
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.list-scroll { flex: 1; height: 0; }
+.card-set { padding: 20rpx 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); }
+.card-head { display: flex; justify-content: space-between; align-items: flex-start; }
+.card-left { flex: 1; min-width: 0; }
+.card-name { font-size: 30rpx; font-weight: 600; color: $text-color-primary; display: block; margin-bottom: 4rpx; }
+.card-source { font-size: 24rpx; color: $text-color-muted; display: block; margin-bottom: 4rpx; }
+.card-time { font-size: 22rpx; color: $text-color-placeholder; display: block; }
+.card-amount { font-size: 32rpx; font-weight: 700; color: $primary-color-dark; margin: 0 16rpx; white-space: nowrap; }
+.card-status { padding: 4rpx 14rpx; border-radius: 8rpx; font-size: 22rpx; font-weight: 500; flex-shrink: 0; }
+.card-status.settled { background: $success-color-bg; color: $success-color; }
+.card-status.pending { background: $warning-color-bg; color: $warning-color; }
+.load-tip { display: flex; justify-content: center; padding: 36rpx 0; }
+.dot-row { display: flex; gap: 8rpx; }
+.pulse-dot { width: 9rpx; height: 9rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite; }
+.pulse-dot:nth-child(2) { animation-delay: 0.2s; } .pulse-dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-muted; }
+.empty-state { display: flex; align-items: center; justify-content: center; padding: 100rpx 0; }
+.empty-text { font-size: 28rpx; color: $text-color-muted; }
+</style>

+ 62 - 0
haha-admin-mp/src/pages/distribution/referral.vue

@@ -0,0 +1,62 @@
+<template>
+  <view class="page">
+    <NavBar title="推荐记录" :showBack="true" />
+    <scroll-view class="list-scroll" scroll-y @scrolltolower="loadMore">
+      <view class="card-set">
+        <view class="card" v-for="item in list" :key="item.id">
+          <view class="card-head">
+            <view class="card-left">
+              <text class="card-name">{{ item.inviteeName || '用户' + item.inviteeId }}</text>
+              <text class="card-time">{{ item.createTime }}</text>
+            </view>
+            <view class="card-status" :class="statusClass(item.status)">
+              <text>{{ item.status === 1 ? '已激活' : item.status === 2 ? '已奖励' : '待激活' }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+      <view class="load-tip" v-if="loading"><view class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view></view>
+      <view class="end-tip" v-if="!hasMore && list.length > 0"><text>没有更多了</text></view>
+      <view class="empty-state" v-if="!loading && list.length === 0"><text class="empty-text">暂无推荐记录</text></view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { get } from '@/utils/request';
+import { DEFAULT_PAGE_SIZE } from '@/utils/constants';
+const list = ref<any[]>([]); const loading = ref(false); const hasMore = ref(true); const page = ref(1);
+const statusClass = (s: number) => s === 2 ? 'done' : s === 1 ? 'active' : 'pending';
+const loadData = async () => {
+  loading.value = true;
+  try { const res = await get('/distribution/referral/list', { page: page.value, pageSize: DEFAULT_PAGE_SIZE }); if (page.value === 1) list.value = res.list || []; else list.value = [...list.value, ...(res.list || [])]; hasMore.value = list.value.length < (res.total || 0); } catch { }
+  loading.value = false;
+};
+const loadMore = () => { if (loading.value || !hasMore.value) return; page.value++; loadData(); };
+onMounted(() => loadData());
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.list-scroll { flex: 1; height: 0; }
+.card-set { padding: 20rpx 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); }
+.card-head { display: flex; justify-content: space-between; align-items: center; }
+.card-left { flex: 1; }
+.card-name { font-size: 30rpx; font-weight: 600; color: $text-color-primary; display: block; margin-bottom: 6rpx; }
+.card-time { font-size: 24rpx; color: $text-color-muted; }
+.card-status { padding: 4rpx 14rpx; border-radius: 8rpx; font-size: 22rpx; font-weight: 500; }
+.card-status.done { background: $success-color-bg; color: $success-color; }
+.card-status.active { background: $info-color-bg; color: $info-color; }
+.card-status.pending { background: $warning-color-bg; color: $warning-color; }
+.load-tip { display: flex; justify-content: center; padding: 36rpx 0; }
+.dot-row { display: flex; gap: 8rpx; }
+.pulse-dot { width: 9rpx; height: 9rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite; }
+.pulse-dot:nth-child(2) { animation-delay: 0.2s; } .pulse-dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-muted; }
+.empty-state { display: flex; align-items: center; justify-content: center; padding: 100rpx 0; }
+.empty-text { font-size: 28rpx; color: $text-color-muted; }
+</style>

+ 36 - 1
haha-admin-mp/src/pages/index/index.vue

@@ -94,6 +94,39 @@
         </view>
       </view>
 
+            <!-- 营销中心 -->
+      <view class="quick-card">
+        <view class="card-title">
+          <text>营销中心</text>
+        </view>
+        <view class="quick-grid">
+          <view class="quick-item" @click="navigateTo('/pages/marketing/activity-list')">
+            <view class="quick-icon">
+              <view class="icon-apply"></view>
+            </view>
+            <text class="quick-name">活动管理</text>
+          </view>
+          <view class="quick-item" @click="navigateTo('/pages/marketing/coupon-list')">
+            <view class="quick-icon">
+              <view class="icon-replenisher"></view>
+            </view>
+            <text class="quick-name">优惠券</text>
+          </view>
+          <view class="quick-item" @click="navigateTo('/pages/orders/refund-list')">
+            <view class="quick-icon">
+              <view class="icon-staff"></view>
+            </view>
+            <text class="quick-name">退款管理</text>
+          </view>
+          <view class="quick-item" @click="navigateTo('/pages/products/master-library')">
+            <view class="quick-icon">
+              <view class="icon-shop-quick"></view>
+            </view>
+            <text class="quick-name">商品总库</text>
+          </view>
+        </view>
+      </view>
+
       <!-- 商户运营 -->
       <view class="quick-card">
         <view class="card-title">
@@ -155,7 +188,7 @@ const noticeList = ref<any[]>([]);
 const todoItems = computed(() => [
   { key: 'pendingRefunds', label: '待处理退款', count: todo.value.pendingRefunds || 0, url: '/pages/orders/list?tab=refund' },
   { key: 'unpaidOrders', label: '未支付订单', count: todo.value.unpaidOrders || 0, url: '/pages/orders/list?tab=unpaid' },
-  { key: 'lowStockItems', label: '待补货', count: todo.value.lowStockItems || 0, url: '/pages/inventory/list' }
+  { key: 'lowStockItems', label: '待补货', count: todo.value.lowStockItems || 0, url: '/pages/inventory/warning' }
 ]);
 
 const formatMoney = (amount: number | string) => {
@@ -715,3 +748,5 @@ onMounted(() => {
   }
 }
 </style>
+
+

+ 174 - 0
haha-admin-mp/src/pages/marketing/activity-detail.vue

@@ -0,0 +1,174 @@
+<template>
+  <view class="page">
+    <NavBar :title="isCreate ? '新建活动' : '活动详情'" :showBack="true" />
+
+    <scroll-view class="detail-scroll" scroll-y>
+      <view class="section">
+        <text class="section-title">基本信息</text>
+        <view class="form-item">
+          <text class="form-label">活动名称</text>
+          <input class="form-input" v-model="form.activityName" placeholder="请输入活动名称" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">活动类型</text>
+          <picker :range="typeOptions" range-key="label" :value="typeIndex" @change="onTypeChange" :disabled="!isCreate">
+            <view class="form-picker">{{ typeOptions[typeIndex]?.label || '请选择' }}</view>
+          </picker>
+        </view>
+        <view class="form-item">
+          <text class="form-label">开始时间</text>
+          <picker mode="date" :value="form.startTime" @change="onStartDateChange">
+            <view class="form-picker">{{ form.startTime || '请选择' }}</view>
+          </picker>
+        </view>
+        <view class="form-item">
+          <text class="form-label">结束时间</text>
+          <picker mode="date" :value="form.endTime" @change="onEndDateChange">
+            <view class="form-picker">{{ form.endTime || '请选择' }}</view>
+          </picker>
+        </view>
+      </view>
+
+      <view class="section" v-if="form.activityType === 2 || form.activityType === 3">
+        <text class="section-title">优惠规则</text>
+        <view class="form-item">
+          <text class="form-label">{{ form.activityType === 2 ? '折扣比例(%)' : '满减金额(元)' }}</text>
+          <input class="form-input" v-model="form.discountValue" type="digit" placeholder="请输入" />
+        </view>
+        <view class="form-item" v-if="form.activityType === 3">
+          <text class="form-label">满减门槛(元)</text>
+          <input class="form-input" v-model="form.thresholdAmount" type="digit" placeholder="请输入满减门槛" />
+        </view>
+      </view>
+
+      <view class="section" v-if="!isCreate">
+        <text class="section-title">统计数据</text>
+        <view class="stats-grid" v-if="stats">
+          <view class="stat-item"><text class="stat-value">{{ stats.totalOrders || 0 }}</text><text class="stat-label">订单数</text></view>
+          <view class="stat-item"><text class="stat-value">{{ formatMoney(stats.totalAmount || 0) }}</text><text class="stat-label">销售额</text></view>
+          <view class="stat-item"><text class="stat-value">{{ formatMoney(stats.discountAmount || 0) }}</text><text class="stat-label">优惠金额</text></view>
+        </view>
+      </view>
+    </scroll-view>
+
+    <view class="bottom-actions" v-if="!isCreate">
+      <view class="action-row">
+        <view v-if="form.status === 0" class="action-btn primary" @click="handlePublish"><text>发布</text></view>
+        <view v-if="form.status === 1" class="action-btn warn" @click="handlePause"><text>暂停</text></view>
+        <view v-if="form.status === 2" class="action-btn primary" @click="handleResume"><text>恢复</text></view>
+        <view class="action-btn grey" @click="handleDelete"><text>删除</text></view>
+      </view>
+      <view class="save-btn" @click="handleSave"><text>保存</text></view>
+    </view>
+
+    <view class="bottom-actions" v-if="isCreate">
+      <view class="save-btn" @click="handleCreate"><text>创建活动</text></view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app';
+import NavBar from '@/components/NavBar.vue';
+import { getActivityById, createActivity, updateActivity, publishActivity, pauseActivity, resumeActivity, deleteActivity, getActivityStatistics } from '@/api/activity';
+import { ActivityTypeText } from '@/utils/constants';
+import { formatMoney as formatMoneyUtil, showToast, showConfirm } from '@/utils/common';
+
+const typeOptions = [
+  { label: ActivityTypeText[1], value: 1 },
+  { label: ActivityTypeText[2], value: 2 },
+  { label: ActivityTypeText[3], value: 3 },
+  { label: ActivityTypeText[4], value: 4 }
+];
+
+const isCreate = ref(true);
+const typeIndex = ref(0);
+const form = ref<any>({ activityType: 1, startTime: '', endTime: '', discountValue: '', thresholdAmount: '' });
+const stats = ref<any>(null);
+const formatMoney = formatMoneyUtil;
+
+onLoad((options?: Record<string, string>) => {
+  const id = options?.id ? Number(options.id) : 0;
+  if (id > 0) { isCreate.value = false; loadDetail(id); }
+});
+
+const loadDetail = async (id: number) => {
+  try {
+    const data = await getActivityById(id);
+    form.value = data;
+    typeIndex.value = typeOptions.findIndex(t => t.value === data.activityType);
+    if (typeIndex.value < 0) typeIndex.value = 0;
+    loadStats(id);
+  } catch { showToast('加载失败'); uni.navigateBack(); }
+};
+
+const loadStats = async (id: number) => {
+  try { stats.value = await getActivityStatistics(id); } catch { /* ignore */ }
+};
+
+const onTypeChange = (e: any) => {
+  typeIndex.value = e.detail.value;
+  form.value.activityType = typeOptions[typeIndex.value].value;
+};
+
+const onStartDateChange = (e: any) => { form.value.startTime = e.detail.value; };
+const onEndDateChange = (e: any) => { form.value.endTime = e.detail.value; };
+
+const handleCreate = async () => {
+  try {
+    await createActivity(form.value);
+    showToast('创建成功', 'success');
+    setTimeout(() => uni.navigateBack(), 1500);
+  } catch { showToast('创建失败'); }
+};
+
+const handleSave = async () => {
+  try {
+    await updateActivity(form.value.id, form.value);
+    showToast('保存成功', 'success');
+  } catch { showToast('保存失败'); }
+};
+
+const handlePublish = async () => {
+  if (!(await showConfirm('确认发布?'))) return;
+  try { await publishActivity(form.value.id); showToast('已发布', 'success'); form.value.status = 1; } catch { showToast('操作失败'); }
+};
+const handlePause = async () => {
+  if (!(await showConfirm('确认暂停?'))) return;
+  try { await pauseActivity(form.value.id); showToast('已暂停', 'success'); form.value.status = 2; } catch { showToast('操作失败'); }
+};
+const handleResume = async () => {
+  try { await resumeActivity(form.value.id); showToast('已恢复', 'success'); form.value.status = 1; } catch { showToast('操作失败'); }
+};
+const handleDelete = async () => {
+  if (!(await showConfirm('确认删除?'))) return;
+  try { await deleteActivity(form.value.id); showToast('已删除', 'success'); setTimeout(() => uni.navigateBack(), 1500); } catch { showToast('操作失败'); }
+};
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.detail-scroll { flex: 1; height: 0; }
+.section { background: $bg-color-card; margin-bottom: $spacing-2; padding: $spacing-3 $spacing-4; }
+.section-title { display: block; font-size: 28rpx; font-weight: 600; color: $text-color-primary; margin-bottom: $spacing-3; padding-bottom: $spacing-2; border-bottom: 1rpx solid $border-color-light; }
+.form-item { display: flex; align-items: center; justify-content: space-between; padding: $spacing-2 0; border-bottom: 1rpx solid $border-color-light; }
+.form-item:last-child { border-bottom: none; }
+.form-label { font-size: 28rpx; color: $text-color-secondary; flex-shrink: 0; width: 180rpx; }
+.form-input { flex: 1; text-align: right; font-size: 28rpx; color: $text-color-primary; }
+.form-picker { flex: 1; text-align: right; font-size: 28rpx; color: $text-color-muted; }
+.stats-grid { display: flex; }
+.stat-item { flex: 1; display: flex; flex-direction: column; align-items: center; padding: $spacing-2; }
+.stat-value { font-size: $font-size-xl; font-weight: 700; color: $text-color-primary; margin-bottom: $spacing-1; }
+.stat-label { font-size: 22rpx; color: $text-color-muted; }
+.bottom-actions { padding: $spacing-3 $spacing-4; padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); background: $bg-color-card; border-top: 1rpx solid $border-color-light; flex-shrink: 0; }
+.action-row { display: flex; gap: $spacing-2; margin-bottom: $spacing-3; }
+.action-btn { flex: 1; padding: 20rpx 0; border-radius: $radius-base; display: flex; align-items: center; justify-content: center; }
+.action-btn text { font-size: 28rpx; font-weight: 500; }
+.action-btn.primary { background: $primary-color-bg; } .action-btn.primary text { color: $primary-color-dark; }
+.action-btn.warn { background: $warning-color-bg; } .action-btn.warn text { color: $warning-color; }
+.action-btn.grey { background: $bg-color-page; } .action-btn.grey text { color: $text-color-secondary; }
+.save-btn { width: 100%; background: $primary-color; padding: 28rpx 0; border-radius: $radius-xl; display: flex; align-items: center; justify-content: center; }
+.save-btn text { font-size: $font-size-lg; font-weight: 600; color: $text-color-primary; }
+</style>
+

+ 163 - 0
haha-admin-mp/src/pages/marketing/activity-list.vue

@@ -0,0 +1,163 @@
+<template>
+  <view class="page">
+    <NavBar title="活动管理" :showBack="true" />
+
+    <view class="tab-bar">
+      <view
+        v-for="tab in tabs"
+        :key="tab.key"
+        class="tab-item"
+        :class="{ active: currentTab === tab.key }"
+        @click="changeTab(tab.key)"
+      >
+        <text>{{ tab.label }}</text>
+        <view class="tab-line" v-if="currentTab === tab.key"></view>
+      </view>
+    </view>
+
+    <view class="search-bar">
+      <input
+        class="search-input"
+        v-model="keyword"
+        placeholder="搜索活动名称"
+        placeholder-class="search-ph"
+        confirm-type="search"
+        @confirm="handleSearch"
+      />
+    </view>
+
+    <scroll-view class="list-scroll" scroll-y @scrolltolower="loadMore">
+      <view class="card-set">
+        <view
+          class="card"
+          v-for="item in list"
+          :key="item.id"
+          @click="goDetail(item.id)"
+        >
+          <view class="card-head">
+            <view class="card-left">
+              <text class="card-name">{{ item.activityName }}</text>
+              <view class="card-meta">
+                <text class="meta-type">{{ ActivityTypeText[item.activityType] || '未知' }}</text>
+                <text class="meta-time">{{ item.startTime }} ~ {{ item.endTime }}</text>
+              </view>
+            </view>
+            <view class="card-status" :class="statusClass(item.status)">
+              <text>{{ ActivityStatusText[item.status] || '未知' }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="load-tip" v-if="loading">
+        <view class="dot-row">
+          <view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view>
+        </view>
+      </view>
+      <view class="end-tip" v-if="!hasMore && list.length > 0"><text>没有更多了</text></view>
+      <view class="empty-state" v-if="!loading && list.length === 0">
+        <text class="empty-text">暂无活动数据</text>
+      </view>
+    </scroll-view>
+
+    <view class="bottom-btn" @click="goDetail(0)">
+      <text>新建活动</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { getActivityList } from '@/api/activity';
+import { ActivityStatusText, ActivityTypeText } from '@/utils/constants';
+
+const tabs = [
+  { key: '', label: '全部' },
+  { key: '1', label: '进行中' },
+  { key: '3', label: '已结束' }
+];
+
+const currentTab = ref('');
+const list = ref<any[]>([]);
+const loading = ref(false);
+const hasMore = ref(true);
+const page = ref(1);
+const pageSize = 10;
+const keyword = ref('');
+
+const statusClass = (status: number) => {
+  if (status === 1) return 'on';
+  if (status === 2) return 'paused';
+  return 'off';
+};
+
+const loadData = async () => {
+  loading.value = true;
+  try {
+    const params: any = { page: page.value, pageSize };
+    if (keyword.value) params.activityName = keyword.value;
+    if (currentTab.value) params.status = Number(currentTab.value);
+    const res = await getActivityList(params);
+    if (page.value === 1) list.value = res.list || [];
+    else list.value = [...list.value, ...(res.list || [])];
+    hasMore.value = list.value.length < (res.total || 0);
+  } catch { /* ignore */ }
+  loading.value = false;
+};
+
+const loadMore = () => {
+  if (loading.value || !hasMore.value) return;
+  page.value++;
+  loadData();
+};
+
+const changeTab = (key: string) => {
+  currentTab.value = key;
+  page.value = 1;
+  hasMore.value = true;
+  list.value = [];
+  loadData();
+};
+
+const handleSearch = () => { page.value = 1; hasMore.value = true; list.value = []; loadData(); };
+const goDetail = (id: number) => { uni.navigateTo({ url: `/pages/marketing/activity-detail?id=${id}` }); };
+
+onMounted(() => loadData());
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.tab-bar { display: flex; background: $bg-color-card; border-bottom: 1rpx solid $border-color-light; }
+.tab-bar .tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; padding: $spacing-3 0 $spacing-2; position: relative; }
+.tab-bar .tab-item text { font-size: $font-size-base; color: $text-color-secondary; }
+.tab-bar .tab-item.active text { color: $text-color-primary; font-weight: 600; }
+.tab-bar .tab-item .tab-line { position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 60rpx; height: 4rpx; background: $primary-color; border-radius: 2rpx; }
+.search-bar { padding: $spacing-2 $spacing-3; background: $bg-color-card; }
+.search-input { width: 100%; height: 68rpx; background: $bg-color-page; border-radius: 40rpx; padding: 0 24rpx; font-size: $font-size-base; box-sizing: border-box; }
+.search-ph { color: $text-color-muted; font-size: 26rpx; }
+.list-scroll { flex: 1; height: 0; }
+.card-set { padding: 20rpx 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); }
+.card-head { display: flex; justify-content: space-between; align-items: flex-start; }
+.card-left { flex: 1; min-width: 0; }
+.card-name { font-size: 30rpx; font-weight: 600; color: $text-color-primary; margin-bottom: 10rpx; display: block; }
+.card-meta { display: flex; flex-direction: column; gap: 4rpx; }
+.meta-type { font-size: 24rpx; color: $primary-color-dark; font-weight: 500; }
+.meta-time { font-size: 22rpx; color: $text-color-muted; }
+.card-status { padding: 6rpx 16rpx; border-radius: 10rpx; font-size: 22rpx; font-weight: 500; flex-shrink: 0; }
+.card-status.on { background: $success-color-bg; color: $success-color; }
+.card-status.paused { background: $warning-color-bg; color: $warning-color; }
+.card-status.off { background: $bg-color-page; color: $text-color-muted; }
+.load-tip { display: flex; justify-content: center; padding: 36rpx 0; }
+.dot-row { display: flex; gap: 8rpx; }
+.pulse-dot { width: 9rpx; height: 9rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite; }
+.pulse-dot:nth-child(2) { animation-delay: 0.2s; }
+.pulse-dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-muted; }
+.empty-state { display: flex; align-items: center; justify-content: center; padding: 100rpx 0; }
+.empty-text { font-size: 28rpx; color: $text-color-muted; }
+.bottom-btn { background: $primary-color; margin: 20rpx 24rpx; padding: 28rpx 0; border-radius: $radius-xl; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
+.bottom-btn text { font-size: $font-size-lg; font-weight: 600; color: $text-color-primary; }
+</style>

+ 137 - 0
haha-admin-mp/src/pages/marketing/coupon-detail.vue

@@ -0,0 +1,137 @@
+<template>
+  <view class="page">
+    <NavBar :title="isCreate ? '新建优惠券' : '优惠券详情'" :showBack="true" />
+
+    <scroll-view class="detail-scroll" scroll-y>
+      <view class="section">
+        <text class="section-title">基本信息</text>
+        <view class="form-item">
+          <text class="form-label">优惠券名称</text>
+          <input class="form-input" v-model="form.name" placeholder="请输入名称" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">优惠类型</text>
+          <picker :range="typeOptions" range-key="label" :value="typeIndex" @change="onTypeChange" :disabled="!isCreate">
+            <view class="form-picker">{{ typeOptions[typeIndex]?.label || '请选择' }}</view>
+          </picker>
+        </view>
+        <view class="form-item" v-if="form.type === 1">
+          <text class="form-label">满减金额</text>
+          <input class="form-input" v-model="form.amount" type="digit" placeholder="抵扣金额" />
+        </view>
+        <view class="form-item" v-if="form.type === 2">
+          <text class="form-label">折扣比例(%)</text>
+          <input class="form-input" v-model="form.discount" type="number" placeholder="如 80 表示8折" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">最低消费(元)</text>
+          <input class="form-input" v-model="form.minAmount" type="digit" placeholder="使用门槛" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">开始日期</text>
+          <picker mode="date" :value="form.startTime" @change="onStartDateChange">
+            <view class="form-picker">{{ form.startTime || '请选择' }}</view>
+          </picker>
+        </view>
+        <view class="form-item">
+          <text class="form-label">结束日期</text>
+          <picker mode="date" :value="form.endTime" @change="onEndDateChange">
+            <view class="form-picker">{{ form.endTime || '请选择' }}</view>
+          </picker>
+        </view>
+        <view class="form-item">
+          <text class="form-label">发行总量</text>
+          <input class="form-input" v-model="form.totalQuantity" type="number" placeholder="发行数量" />
+        </view>
+      </view>
+
+      <view class="section" v-if="!isCreate && stats">
+        <text class="section-title">统计数据</text>
+        <view class="stats-grid">
+          <view class="stat-item"><text class="stat-value">{{ stats.receivedCount || 0 }}</text><text class="stat-label">已领取</text></view>
+          <view class="stat-item"><text class="stat-value">{{ stats.usedCount || 0 }}</text><text class="stat-label">已使用</text></view>
+          <view class="stat-item"><text class="stat-value">{{ formatMoney(stats.totalDiscount || 0) }}</text><text class="stat-label">总优惠</text></view>
+        </view>
+        <view class="distribute-btn" @click="handleDistribute" v-if="form.status === 1"><text>发放优惠券</text></view>
+      </view>
+    </scroll-view>
+
+    <view class="bottom-actions">
+      <view class="save-btn" @click="isCreate ? handleCreate() : handleSave()"><text>{{ isCreate ? '创建优惠券' : '保存修改' }}</text></view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app';
+import NavBar from '@/components/NavBar.vue';
+import { getCouponById, createCoupon, updateCouponStatus, distributeCoupon, getCouponStatistics } from '@/api/coupon';
+import { CouponTypeText } from '@/utils/constants';
+import { formatMoney as formatMoneyUtil, showToast, showConfirm } from '@/utils/common';
+
+const typeOptions = [{ label: CouponTypeText[1], value: 1 }, { label: CouponTypeText[2], value: 2 }];
+
+const isCreate = ref(true);
+const typeIndex = ref(0);
+const form = ref<any>({ type: 1, startTime: '', endTime: '' });
+const stats = ref<any>(null);
+const formatMoney = formatMoneyUtil;
+
+onLoad((options?: Record<string, string>) => {
+  const id = options?.id ? Number(options.id) : 0;
+  if (id > 0) { isCreate.value = false; loadDetail(id); }
+});
+
+const loadDetail = async (id: number) => {
+  try {
+    const data = await getCouponById(id);
+    form.value = data;
+    typeIndex.value = typeOptions.findIndex(t => t.value === data.type);
+    if (typeIndex.value < 0) typeIndex.value = 0;
+    loadStats(id);
+  } catch { showToast('加载失败'); uni.navigateBack(); }
+};
+
+const loadStats = async (id: number) => {
+  try { stats.value = await getCouponStatistics(id); } catch { /* ignore */ }
+};
+
+const onTypeChange = (e: any) => { typeIndex.value = e.detail.value; form.value.type = typeOptions[typeIndex.value].value; };
+const onStartDateChange = (e: any) => { form.value.startTime = e.detail.value; };
+const onEndDateChange = (e: any) => { form.value.endTime = e.detail.value; };
+
+const handleCreate = async () => {
+  try { await createCoupon(form.value); showToast('创建成功', 'success'); setTimeout(() => uni.navigateBack(), 1500); } catch { showToast('创建失败'); }
+};
+const handleSave = async () => {
+  try { await updateCouponStatus(form.value.id, form.value.status); showToast('保存成功', 'success'); } catch { showToast('保存失败'); }
+};
+
+const handleDistribute = async () => {
+  if (!(await showConfirm('确认向全部用户发放此优惠券?'))) return;
+  try { await distributeCoupon({ templateId: form.value.id, targetType: 1 }); showToast('发放成功', 'success'); } catch { showToast('发放失败'); }
+};
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.detail-scroll { flex: 1; height: 0; }
+.section { background: $bg-color-card; margin-bottom: $spacing-2; padding: $spacing-3 $spacing-4; }
+.section-title { display: block; font-size: 28rpx; font-weight: 600; color: $text-color-primary; margin-bottom: $spacing-3; padding-bottom: $spacing-2; border-bottom: 1rpx solid $border-color-light; }
+.form-item { display: flex; align-items: center; justify-content: space-between; padding: $spacing-2 0; border-bottom: 1rpx solid $border-color-light; }
+.form-item:last-child { border-bottom: none; }
+.form-label { font-size: 28rpx; color: $text-color-secondary; flex-shrink: 0; width: 180rpx; }
+.form-input { flex: 1; text-align: right; font-size: 28rpx; color: $text-color-primary; }
+.form-picker { flex: 1; text-align: right; font-size: 28rpx; color: $text-color-muted; }
+.stats-grid { display: flex; }
+.stat-item { flex: 1; display: flex; flex-direction: column; align-items: center; padding: $spacing-2; }
+.stat-value { font-size: $font-size-xl; font-weight: 700; color: $text-color-primary; margin-bottom: $spacing-1; }
+.stat-label { font-size: 22rpx; color: $text-color-muted; }
+.distribute-btn { margin-top: $spacing-4; background: $primary-color; padding: 24rpx 0; border-radius: $radius-base; display: flex; align-items: center; justify-content: center; }
+.distribute-btn text { font-size: 28rpx; font-weight: 600; color: $text-color-primary; }
+.bottom-actions { padding: $spacing-3 $spacing-4; padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); background: $bg-color-card; border-top: 1rpx solid $border-color-light; flex-shrink: 0; }
+.save-btn { width: 100%; background: $primary-color; padding: 28rpx 0; border-radius: $radius-xl; display: flex; align-items: center; justify-content: center; }
+.save-btn text { font-size: $font-size-lg; font-weight: 600; color: $text-color-primary; }
+</style>
+

+ 118 - 0
haha-admin-mp/src/pages/marketing/coupon-list.vue

@@ -0,0 +1,118 @@
+<template>
+  <view class="page">
+    <NavBar title="优惠券管理" :showBack="true" />
+
+    <view class="search-bar">
+      <input class="search-input" v-model="keyword" placeholder="搜索优惠券名称" placeholder-class="search-ph" confirm-type="search" @confirm="handleSearch" />
+    </view>
+
+    <scroll-view class="list-scroll" scroll-y @scrolltolower="loadMore">
+      <view class="card-set">
+        <view class="card" v-for="item in list" :key="item.id" @click="goDetail(item.id)">
+          <view class="card-head">
+            <view class="card-left">
+              <view class="top-row">
+                <text class="card-name">{{ item.name }}</text>
+                <text class="card-amount">{{ item.type === 1 ? '¥' + (item.amount || 0) : (item.discount || 0) + '%' }}</text>
+              </view>
+              <view class="card-meta">
+                <text class="meta-type">{{ CouponTypeText[item.type] || '未知' }}</text>
+                <text class="meta-range" v-if="item.minAmount">满¥{{ item.minAmount }}可用</text>
+              </view>
+              <text class="card-time">{{ item.startTime }} ~ {{ item.endTime }}</text>
+            </view>
+            <view class="card-right">
+              <view class="card-status" :class="statusClass(item.status)">
+                <text>{{ CouponStatusText[item.status] || '未知' }}</text>
+              </view>
+            </view>
+          </view>
+          <view class="card-foot" v-if="item.statistics">
+            <text class="foot-stat">已领: {{ item.statistics.receivedCount || 0 }}</text>
+            <text class="foot-stat">已用: {{ item.statistics.usedCount || 0 }}</text>
+          </view>
+        </view>
+      </view>
+      <view class="load-tip" v-if="loading"><view class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view></view>
+      <view class="end-tip" v-if="!hasMore && list.length > 0"><text>没有更多了</text></view>
+      <view class="empty-state" v-if="!loading && list.length === 0"><text class="empty-text">暂无优惠券数据</text></view>
+    </scroll-view>
+
+    <view class="bottom-btn" @click="goDetail(0)"><text>新建优惠券</text></view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { getCouponList } from '@/api/coupon';
+import { CouponTypeText, CouponStatusText, DEFAULT_PAGE_SIZE } from '@/utils/constants';
+
+const list = ref<any[]>([]);
+const loading = ref(false);
+const hasMore = ref(true);
+const page = ref(1);
+const pageSize = DEFAULT_PAGE_SIZE;
+const keyword = ref('');
+
+const statusClass = (status: number) => {
+  if (status === 1) return 'on';
+  if (status === 2) return 'off';
+  return 'draft';
+};
+
+const loadData = async () => {
+  loading.value = true;
+  try {
+    const params: any = { page: page.value, pageSize };
+    if (keyword.value) params.name = keyword.value;
+    const res = await getCouponList(params);
+    if (page.value === 1) list.value = res.list || [];
+    else list.value = [...list.value, ...(res.list || [])];
+    hasMore.value = list.value.length < (res.total || 0);
+  } catch { /* ignore */ }
+  loading.value = false;
+};
+
+const loadMore = () => { if (loading.value || !hasMore.value) return; page.value++; loadData(); };
+const handleSearch = () => { page.value = 1; hasMore.value = true; list.value = []; loadData(); };
+const goDetail = (id: number) => { uni.navigateTo({ url: `/pages/marketing/coupon-detail?id=${id}` }); };
+
+onMounted(() => loadData());
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.search-bar { padding: $spacing-2 $spacing-3; background: $bg-color-card; }
+.search-input { width: 100%; height: 68rpx; background: $bg-color-page; border-radius: 40rpx; padding: 0 24rpx; font-size: $font-size-base; box-sizing: border-box; }
+.search-ph { color: $text-color-muted; font-size: 26rpx; }
+.list-scroll { flex: 1; height: 0; }
+.card-set { padding: 20rpx 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); }
+.card-head { display: flex; justify-content: space-between; align-items: flex-start; }
+.card-left { flex: 1; min-width: 0; }
+.top-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
+.card-name { font-size: 30rpx; font-weight: 600; color: $text-color-primary; }
+.card-amount { font-size: 36rpx; font-weight: 700; color: $primary-color-dark; }
+.card-meta { display: flex; gap: 12rpx; margin-bottom: 6rpx; }
+.meta-type { font-size: 22rpx; color: $primary-color-dark; background: $primary-color-bg; padding: 2rpx 10rpx; border-radius: 6rpx; }
+.meta-range { font-size: 22rpx; color: $text-color-muted; }
+.card-time { font-size: 22rpx; color: $text-color-muted; }
+.card-right { flex-shrink: 0; }
+.card-status { padding: 4rpx 14rpx; border-radius: 8rpx; font-size: 22rpx; font-weight: 500; }
+.card-status.on { background: $success-color-bg; color: $success-color; }
+.card-status.off { background: $bg-color-page; color: $text-color-muted; }
+.card-status.draft { background: $warning-color-bg; color: $warning-color; }
+.card-foot { display: flex; gap: 24rpx; margin-top: 16rpx; padding-top: 14rpx; border-top: 1rpx solid $border-color-light; }
+.foot-stat { font-size: 24rpx; color: $text-color-secondary; }
+.load-tip { display: flex; justify-content: center; padding: 36rpx 0; }
+.dot-row { display: flex; gap: 8rpx; }
+.pulse-dot { width: 9rpx; height: 9rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite; }
+.pulse-dot:nth-child(2) { animation-delay: 0.2s; } .pulse-dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-muted; }
+.empty-state { display: flex; align-items: center; justify-content: center; padding: 100rpx 0; }
+.empty-text { font-size: 28rpx; color: $text-color-muted; }
+.bottom-btn { background: $primary-color; margin: 20rpx 24rpx; padding: 28rpx 0; border-radius: $radius-xl; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
+.bottom-btn text { font-size: $font-size-lg; font-weight: 600; color: $text-color-primary; }
+</style>

+ 85 - 0
haha-admin-mp/src/pages/marketing/timed-discount-detail.vue

@@ -0,0 +1,85 @@
+<template>
+  <view class="page">
+    <NavBar :title="isCreate ? '新建定时折扣' : '定时折扣详情'" :showBack="true" />
+    <scroll-view class="detail-scroll" scroll-y>
+      <view class="section">
+        <text class="section-title">基本信息</text>
+        <view class="form-item">
+          <text class="form-label">活动名称</text>
+          <input class="form-input" v-model="form.activityName" placeholder="请输入名称" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">折扣类型</text>
+          <picker :range="typeOptions" range-key="label" :value="typeIndex" @change="onTypeChange" :disabled="!isCreate">
+            <view class="form-picker">{{ typeOptions[typeIndex]?.label || '请选择' }}</view>
+          </picker>
+        </view>
+        <view class="form-item">
+          <text class="form-label">折扣开始</text>
+          <picker mode="date" :value="form.discountStartTime" @change="onStartChange">
+            <view class="form-picker">{{ form.discountStartTime || '请选择' }}</view>
+          </picker>
+        </view>
+        <view class="form-item">
+          <text class="form-label">折扣结束</text>
+          <picker mode="date" :value="form.discountEndTime" @change="onEndChange">
+            <view class="form-picker">{{ form.discountEndTime || '请选择' }}</view>
+          </picker>
+        </view>
+      </view>
+    </scroll-view>
+    <view class="bottom-actions">
+      <view class="save-btn" @click="isCreate ? handleCreate() : handleSave()"><text>{{ isCreate ? '创建' : '保存' }}</text></view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app';
+import NavBar from '@/components/NavBar.vue';
+import { getTimedDiscountById, createTimedDiscount, updateTimedDiscount } from '@/api/timedDiscount';
+import { showToast } from '@/utils/common';
+
+const typeOptions = [{ label: '定时折扣', value: 1 }, { label: '夜间折扣', value: 2 }];
+const isCreate = ref(true);
+const typeIndex = ref(0);
+const form = ref<any>({ activityName: '', discountType: 1, discountStartTime: '', discountEndTime: '' });
+
+onLoad((options?: Record<string, string>) => {
+  const id = options?.id ? Number(options.id) : 0;
+  if (id > 0) { isCreate.value = false; loadDetail(id); }
+});
+
+const loadDetail = async (id: number) => {
+  try { const data = await getTimedDiscountById(id); form.value = data; typeIndex.value = typeOptions.findIndex(t => t.value === data.discountType) || 0; } catch { }
+};
+
+const onTypeChange = (e: any) => { typeIndex.value = e.detail.value; form.value.discountType = typeOptions[typeIndex.value].value; };
+const onStartChange = (e: any) => { form.value.discountStartTime = e.detail.value; };
+const onEndChange = (e: any) => { form.value.discountEndTime = e.detail.value; };
+
+const handleCreate = async () => {
+  try { await createTimedDiscount(form.value); showToast('创建成功', 'success'); setTimeout(() => uni.navigateBack(), 1500); } catch { showToast('创建失败'); }
+};
+
+const handleSave = async () => {
+  try { await updateTimedDiscount(form.value.id, form.value); showToast('保存成功', 'success'); } catch { showToast('保存失败'); }
+};
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.detail-scroll { flex: 1; height: 0; }
+.section { background: $bg-color-card; margin-bottom: $spacing-2; padding: $spacing-3 $spacing-4; }
+.section-title { display: block; font-size: 28rpx; font-weight: 600; color: $text-color-primary; margin-bottom: $spacing-3; padding-bottom: $spacing-2; border-bottom: 1rpx solid $border-color-light; }
+.form-item { display: flex; align-items: center; justify-content: space-between; padding: $spacing-2 0; border-bottom: 1rpx solid $border-color-light; }
+.form-item:last-child { border-bottom: none; }
+.form-label { font-size: 28rpx; color: $text-color-secondary; flex-shrink: 0; width: 180rpx; }
+.form-input { flex: 1; text-align: right; font-size: 28rpx; color: $text-color-primary; }
+.form-picker { flex: 1; text-align: right; font-size: 28rpx; color: $text-color-muted; }
+.bottom-actions { padding: $spacing-3 $spacing-4; padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); background: $bg-color-card; border-top: 1rpx solid $border-color-light; flex-shrink: 0; }
+.save-btn { width: 100%; background: $primary-color; padding: 28rpx 0; border-radius: $radius-xl; display: flex; align-items: center; justify-content: center; }
+.save-btn text { font-size: $font-size-lg; font-weight: 600; color: $text-color-primary; }
+</style>
+

+ 63 - 0
haha-admin-mp/src/pages/marketing/timed-discount-list.vue

@@ -0,0 +1,63 @@
+<template>
+  <view class="page">
+    <NavBar title="定时折扣" :showBack="true" />
+    <view class="search-bar"><input class="search-input" v-model="keyword" placeholder="搜索活动名称" placeholder-class="search-ph" @confirm="handleSearch" /></view>
+    <scroll-view class="list-scroll" scroll-y @scrolltolower="loadMore">
+      <view class="card-set">
+        <view class="card" v-for="item in list" :key="item.id" @click="goDetail(item.id)">
+          <view class="card-head">
+            <view class="card-left"><text class="card-name">{{ item.activityName }}</text><view class="card-meta"><text class="meta-type">{{ discountTypeMap[item.discountType] || '折扣' }}</text><text class="meta-time">{{ item.discountStartTime }} ~ {{ item.discountEndTime }}</text></view></view>
+            <view class="card-status" :class="statusClass(item.status)"><text>{{ TimedDiscountStatusText[item.status] || '未知' }}</text></view>
+          </view>
+        </view>
+      </view>
+      <view class="load-tip" v-if="loading"><view class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view></view>
+      <view class="end-tip" v-if="!hasMore && list.length > 0"><text>没有更多了</text></view>
+      <view class="empty-state" v-if="!loading && list.length === 0"><text class="empty-text">暂无数据</text></view>
+    </scroll-view>
+    <view class="bottom-btn" @click="goDetail(0)"><text>新建定时折扣</text></view>
+  </view>
+</template>
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { getTimedDiscountList } from '@/api/timedDiscount';
+import { TimedDiscountStatusText } from '@/utils/constants';
+const list = ref<any[]>([]); const loading = ref(false); const hasMore = ref(true); const page = ref(1); const keyword = ref('');
+const discountTypeMap: Record<number,string> = { 1: '定时折扣', 2: '夜间折扣', 3: '节假日折扣' };
+const statusClass = (status: number) => status === 1 ? 'on' : status === 2 ? 'paused' : 'off';
+const loadData = async () => { loading.value = true; try { const params: any = { page: page.value, pageSize: 10 }; if (keyword.value) params.activityName = keyword.value; const res = await getTimedDiscountList(params); if (page.value === 1) list.value = res.list || []; else list.value = [...list.value, ...(res.list || [])]; hasMore.value = list.value.length < (res.total || 0); } catch { } loading.value = false; };
+const loadMore = () => { if (loading.value || !hasMore.value) return; page.value++; loadData(); };
+const handleSearch = () => { page.value = 1; hasMore.value = true; list.value = []; loadData(); };
+const goDetail = (id: number) => { uni.navigateTo({ url: '/pages/marketing/timed-discount-detail?id=' + id }); };
+onMounted(() => loadData());
+</script>
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.search-bar { padding: $spacing-2 $spacing-3; background: $bg-color-card; }
+.search-input { width: 100%; height: 68rpx; background: $bg-color-page; border-radius: 40rpx; padding: 0 24rpx; font-size: $font-size-base; box-sizing: border-box; }
+.search-ph { color: $text-color-muted; font-size: 26rpx; }
+.list-scroll { flex: 1; height: 0; }
+.card-set { padding: 20rpx 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); }
+.card-head { display: flex; justify-content: space-between; align-items: flex-start; }
+.card-left { flex: 1; min-width: 0; }
+.card-name { font-size: 30rpx; font-weight: 600; color: $text-color-primary; display: block; margin-bottom: 8rpx; }
+.card-meta { display: flex; flex-direction: column; gap: 4rpx; }
+.meta-type { font-size: 24rpx; color: $primary-color-dark; font-weight: 500; }
+.meta-time { font-size: 22rpx; color: $text-color-muted; }
+.card-status { padding: 6rpx 16rpx; border-radius: 10rpx; font-size: 22rpx; font-weight: 500; flex-shrink: 0; }
+.card-status.on { background: $success-color-bg; color: $success-color; }
+.card-status.paused { background: $warning-color-bg; color: $warning-color; }
+.card-status.off { background: $bg-color-page; color: $text-color-muted; }
+.load-tip { display: flex; justify-content: center; padding: 36rpx 0; }
+.dot-row { display: flex; gap: 8rpx; }
+.pulse-dot { width: 9rpx; height: 9rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite; }
+.pulse-dot:nth-child(2) { animation-delay: 0.2s; } .pulse-dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-muted; }
+.empty-state { display: flex; align-items: center; justify-content: center; padding: 100rpx 0; }
+.empty-text { font-size: 28rpx; color: $text-color-muted; }
+.bottom-btn { background: $primary-color; margin: 20rpx 24rpx; padding: 28rpx 0; border-radius: $radius-xl; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
+.bottom-btn text { font-size: $font-size-lg; font-weight: 600; color: $text-color-primary; }
+</style>

+ 63 - 0
haha-admin-mp/src/pages/marketing/user-coupon-list.vue

@@ -0,0 +1,63 @@
+<template>
+  <view class="page">
+    <NavBar title="用户优惠券" :showBack="true" />
+    <view class="search-bar"><input class="search-input" v-model="keyword" placeholder="搜索优惠券码" placeholder-class="search-ph" @confirm="handleSearch" /></view>
+    <scroll-view class="list-scroll" scroll-y @scrolltolower="loadMore">
+      <view class="card-set">
+        <view class="card" v-for="item in list" :key="item.id">
+          <view class="card-head">
+            <view class="card-left"><text class="card-name">{{ item.couponName || '优惠券' }}</text><text class="card-user">用户ID: {{ item.userId }}</text></view>
+            <view class="card-amount">${{ formatMoney(item.amount || 0) }}</view>
+            <view class="card-status" :class="statusClass(item.status)"><text>{{ statusText(item.status) }}</text></view>
+          </view>
+          <view class="card-foot"><text class="foot-time">领取: {{ item.receiveTime || '-' }}</text><text class="foot-time" v-if="item.useTime">使用: {{ item.useTime }}</text></view>
+        </view>
+      </view>
+      <view class="load-tip" v-if="loading"><view class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view></view>
+      <view class="end-tip" v-if="!hasMore && list.length > 0"><text>没有更多了</text></view>
+      <view class="empty-state" v-if="!loading && list.length === 0"><text class="empty-text">暂无数据</text></view>
+    </scroll-view>
+  </view>
+</template>
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { getUserCouponList } from '@/api/userCoupon';
+import { formatMoney } from '@/utils/common';
+import { DEFAULT_PAGE_SIZE } from '@/utils/constants';
+const list = ref<any[]>([]); const loading = ref(false); const hasMore = ref(true); const page = ref(1); const keyword = ref('');
+const statusText = (status: number) => ({ 0: '未使用', 1: '已使用', 2: '已过期' } as Record<number,string>)[status] || '未知';
+const statusClass = (status: number) => status === 0 ? 'on' : status === 1 ? 'used' : 'off';
+const loadData = async () => { loading.value = true; try { const params: any = { page: page.value, pageSize: DEFAULT_PAGE_SIZE }; if (keyword.value) params.couponCode = keyword.value; const res = await getUserCouponList(params); if (page.value === 1) list.value = res.list || []; else list.value = [...list.value, ...(res.list || [])]; hasMore.value = list.value.length < (res.total || 0); } catch { } loading.value = false; };
+const loadMore = () => { if (loading.value || !hasMore.value) return; page.value++; loadData(); };
+const handleSearch = () => { page.value = 1; hasMore.value = true; list.value = []; loadData(); };
+onMounted(() => loadData());
+</script>
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.search-bar { padding: $spacing-2 $spacing-3; background: $bg-color-card; }
+.search-input { width: 100%; height: 68rpx; background: $bg-color-page; border-radius: 40rpx; padding: 0 24rpx; font-size: $font-size-base; box-sizing: border-box; }
+.search-ph { color: $text-color-muted; font-size: 26rpx; }
+.list-scroll { flex: 1; height: 0; }
+.card-set { padding: 20rpx 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); }
+.card-head { display: flex; justify-content: space-between; align-items: flex-start; }
+.card-left { flex: 1; min-width: 0; }
+.card-name { font-size: 30rpx; font-weight: 600; color: $text-color-primary; display: block; margin-bottom: 6rpx; }
+.card-user { font-size: 24rpx; color: $text-color-muted; display: block; }
+.card-amount { font-size: 32rpx; font-weight: 700; color: $primary-color-dark; margin: 0 24rpx; }
+.card-status { padding: 4rpx 14rpx; border-radius: 8rpx; font-size: 22rpx; font-weight: 500; flex-shrink: 0; }
+.card-status.on { background: $success-color-bg; color: $success-color; }
+.card-status.used { background: $info-color-bg; color: $info-color; }
+.card-status.off { background: $bg-color-page; color: $text-color-muted; }
+.card-foot { display: flex; gap: 24rpx; margin-top: 14rpx; padding-top: 14rpx; border-top: 1rpx solid $border-color-light; }
+.foot-time { font-size: 22rpx; color: $text-color-muted; }
+.load-tip { display: flex; justify-content: center; padding: 36rpx 0; }
+.dot-row { display: flex; gap: 8rpx; }
+.pulse-dot { width: 9rpx; height: 9rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite; }
+.pulse-dot:nth-child(2) { animation-delay: 0.2s; } .pulse-dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-muted; }
+.empty-state { display: flex; align-items: center; justify-content: center; padding: 100rpx 0; }
+.empty-text { font-size: 28rpx; color: $text-color-muted; }
+</style>

+ 69 - 0
haha-admin-mp/src/pages/my/my.vue

@@ -93,6 +93,26 @@
               </view>
               <view class="menu-arrow"></view>
             </view>
+            <view class="menu-item" @click="navigateTo('/pages/orders/refund-list')">
+              <view class="menu-icon rose">
+                <view class="icon-warning"></view>
+              </view>
+              <view class="menu-content">
+                <text class="menu-name">退款管理</text>
+                <text class="menu-desc">退款申请审核</text>
+              </view>
+              <view class="menu-arrow"></view>
+            </view>
+            <view class="menu-item" @click="navigateTo('/pages/products/master-library')">
+              <view class="menu-icon green">
+                <view class="icon-product"></view>
+              </view>
+              <view class="menu-content">
+                <text class="menu-name">商品总库</text>
+                <text class="menu-desc">全局商品库查看</text>
+              </view>
+              <view class="menu-arrow"></view>
+            </view>
 
             <view class="menu-item" @click="navigateTo('/pages/customer/list')">
               <view class="menu-icon green">
@@ -135,6 +155,53 @@
           </view>
         </view>
 
+        
+        <!-- Marketing menu -->
+        <view class="menu-card" v-if="!isReplenisherUser">
+          <text class="menu-title">营销管理</text>
+          <view class="menu-list">
+            <view class="menu-item" @click="navigateTo('/pages/marketing/activity-list')">
+              <view class="menu-icon amber">
+                <view class="icon-product"></view>
+              </view>
+              <view class="menu-content">
+                <text class="menu-name">活动管理</text>
+                <text class="menu-desc">营销活动管理</text>
+              </view>
+              <view class="menu-arrow"></view>
+            </view>
+            <view class="menu-item" @click="navigateTo('/pages/marketing/coupon-list')">
+              <view class="menu-icon purple">
+                <view class="icon-product"></view>
+              </view>
+              <view class="menu-content">
+                <text class="menu-name">优惠券管理</text>
+                <text class="menu-desc">创建和管理优惠券</text>
+              </view>
+              <view class="menu-arrow"></view>
+            </view>
+            <view class="menu-item" @click="navigateTo('/pages/marketing/user-coupon-list')">
+              <view class="menu-icon cyan">
+                <view class="icon-user"></view>
+              </view>
+              <view class="menu-content">
+                <text class="menu-name">用户优惠券</text>
+                <text class="menu-desc">查看用户领券情况</text>
+              </view>
+              <view class="menu-arrow"></view>
+            </view>
+            <view class="menu-item" @click="navigateTo('/pages/marketing/timed-discount-list')">
+              <view class="menu-icon rose">
+                <view class="icon-chart"></view>
+              </view>
+              <view class="menu-content">
+                <text class="menu-name">定时折扣</text>
+                <text class="menu-desc">管理限时折扣活动</text>
+              </view>
+              <view class="menu-arrow"></view>
+            </view>
+          </view>
+        </view>
         <!-- Admin system menu -->
         <view class="menu-card" v-if="!isReplenisherUser">
           <text class="menu-title">系统设置</text>
@@ -642,3 +709,5 @@ $font-stack: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue',
   }
 }
 </style>
+
+

+ 60 - 0
haha-admin-mp/src/pages/orders/refund-detail.vue

@@ -0,0 +1,60 @@
+<template>
+  <view class="page">
+    <NavBar title="退款详情" :showBack="true" />
+    <scroll-view class="detail-scroll" scroll-y v-if="detail">
+      <view class="section">
+        <text class="section-title">退款信息</text>
+        <view class="info-row"><text class="info-label">订单编号</text><text class="info-value">{{ detail.orderNo }}</text></view>
+        <view class="info-row"><text class="info-label">退款金额</text><text class="info-value amount">{{ formatMoney(detail.refundAmount) }}</text></view>
+        <view class="info-row"><text class="info-label">申请时间</text><text class="info-value">{{ detail.createTime }}</text></view>
+        <view class="info-row"><text class="info-label">状态</text><view class="card-status" :class="statusClass(detail.status)"><text>{{ RefundStatusText[detail.status] }}</text></view></view>
+      </view>
+    </scroll-view>
+    <view class="bottom-actions" v-if="detail && detail.status === 0">
+      <view class="action-row">
+        <view class="action-btn approve" @click="handleReview(true)"><text>通过</text></view>
+        <view class="action-btn reject" @click="handleReview(false)"><text>驳回</text></view>
+      </view>
+      <input class="remark-input" v-model="remark" placeholder="输入审核备注(可选)" />
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app';
+import NavBar from '@/components/NavBar.vue';
+import { getRefundDetail, reviewRefund } from '@/api/refund';
+import { RefundStatusText } from '@/utils/constants';
+import { formatMoney, showToast, showConfirm } from '@/utils/common';
+const detail = ref<any>(null); const remark = ref('');
+onLoad((options?: Record<string, string>) => { const id = options?.id ? Number(options.id) : 0; if (id) loadDetail(id); });
+const loadDetail = async (id: number) => { try { detail.value = await getRefundDetail(id); } catch { showToast('加载失败'); uni.navigateBack(); } };
+const statusClass = (status: number) => status === 0 ? 'pending' : status === 1 ? 'approved' : 'rejected';
+const handleReview = async (approved: boolean) => {
+  if (!(await showConfirm(approved ? '确认通过?' : '确认驳回?'))) return;
+  try { await reviewRefund(detail.value.id, { approved, remark: remark.value }); showToast(approved ? '已通过' : '已驳回', 'success'); setTimeout(() => uni.navigateBack(), 1500); } catch { showToast('操作失败'); }
+};
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.detail-scroll { flex: 1; height: 0; }
+.section { background: $bg-color-card; margin-bottom: $spacing-2; padding: $spacing-3 $spacing-4; }
+.section-title { display: block; font-size: 28rpx; font-weight: 600; color: $text-color-primary; margin-bottom: $spacing-3; padding-bottom: $spacing-2; border-bottom: 1rpx solid $border-color-light; }
+.info-row { display: flex; align-items: center; justify-content: space-between; padding: $spacing-2 0; border-bottom: 1rpx solid $border-color-light; }
+.info-label { font-size: 28rpx; color: $text-color-secondary; flex-shrink: 0; }
+.info-value { font-size: 28rpx; color: $text-color-primary; text-align: right; }
+.info-value.amount { font-weight: 700; color: $primary-color-dark; font-size: 32rpx; }
+.card-status { padding: 4rpx 16rpx; border-radius: 8rpx; font-size: 22rpx; font-weight: 500; }
+.card-status.pending { background: $warning-color-bg; color: $warning-color; }
+.card-status.approved { background: $success-color-bg; color: $success-color; }
+.card-status.rejected { background: $error-color-bg; color: $error-color; }
+.bottom-actions { padding: $spacing-3 $spacing-4; padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); background: $bg-color-card; border-top: 1rpx solid $border-color-light; flex-shrink: 0; }
+.action-row { display: flex; gap: $spacing-2; margin-bottom: $spacing-3; }
+.action-btn { flex: 1; padding: 24rpx 0; border-radius: $radius-base; display: flex; align-items: center; justify-content: center; }
+.action-btn.approve { background: $success-color-bg; } .action-btn.approve text { color: $success-color; }
+.action-btn.reject { background: $error-color-bg; } .action-btn.reject text { color: $error-color; }
+.remark-input { width: 100%; height: 80rpx; background: $bg-color-page; border-radius: $radius-base; padding: 0 20rpx; font-size: 28rpx; box-sizing: border-box; }
+</style>
+

+ 109 - 0
haha-admin-mp/src/pages/orders/refund-list.vue

@@ -0,0 +1,109 @@
+<template>
+  <view class="page">
+    <NavBar title="退款管理" :showBack="true" />
+    <view class="tab-bar">
+      <view v-for="tab in tabs" :key="tab.key" class="tab-item" :class="{ active: currentTab === tab.key }" @click="changeTab(tab.key)">
+        <text>{{ tab.label }}</text>
+        <view class="tab-line" v-if="currentTab === tab.key"></view>
+      </view>
+    </view>
+    <scroll-view class="list-scroll" scroll-y @scrolltolower="loadMore">
+      <view class="card-set">
+        <view class="card" v-for="item in list" :key="item.id" @click="goDetail(item.id)">
+          <view class="card-head">
+            <view class="card-left">
+              <text class="card-no">{{ item.orderNo }}</text>
+              <text class="card-time">{{ item.createTime }}</text>
+            </view>
+            <view class="card-amount">{{ formatMoney(item.refundAmount || 0) }}</view>
+            <view class="card-status" :class="statusClass(item.status)">
+              <text>{{ RefundStatusText[item.status] || '未知' }}</text>
+            </view>
+          </view>
+          <view class="card-foot">
+            <text class="foot-reason">原因: {{ item.reason || '-' }}</text>
+          </view>
+        </view>
+      </view>
+      <view class="load-tip" v-if="loading"><view class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view></view>
+      <view class="end-tip" v-if="!hasMore && list.length > 0"><text>没有更多了</text></view>
+      <view class="empty-state" v-if="!loading && list.length === 0"><text class="empty-text">暂无退款数据</text></view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { getRefundList } from '@/api/refund';
+import { RefundStatusText, DEFAULT_PAGE_SIZE } from '@/utils/constants';
+import { formatMoney } from '@/utils/common';
+
+const tabs = [
+  { key: '', label: '全部' },
+  { key: '0', label: '待审核' },
+  { key: '1', label: '已通过' },
+  { key: '2', label: '已驳回' }
+];
+const currentTab = ref('');
+const list = ref<any[]>([]);
+const loading = ref(false);
+const hasMore = ref(true);
+const page = ref(1);
+const pageSize = DEFAULT_PAGE_SIZE;
+
+const statusClass = (status: number) => {
+  if (status === 0) return 'pending';
+  if (status === 1) return 'approved';
+  return 'rejected';
+};
+
+const loadData = async () => {
+  loading.value = true;
+  try {
+    const params: any = { page: page.value, pageSize };
+    if (currentTab.value) params.status = Number(currentTab.value);
+    const res = await getRefundList(params);
+    if (page.value === 1) list.value = res.list || [];
+    else list.value = [...list.value, ...(res.list || [])];
+    hasMore.value = list.value.length < (res.total || 0);
+  } catch { }
+  loading.value = false;
+};
+
+const loadMore = () => { if (loading.value || !hasMore.value) return; page.value++; loadData(); };
+const changeTab = (key: string) => { currentTab.value = key; page.value = 1; hasMore.value = true; list.value = []; loadData(); };
+const goDetail = (id: number) => { uni.navigateTo({ url: '/pages/orders/refund-detail?id=' + id }); };
+onMounted(() => loadData());
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.tab-bar { display: flex; background: $bg-color-card; border-bottom: 1rpx solid $border-color-light; }
+.tab-bar .tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; padding: $spacing-3 0 $spacing-2; position: relative; }
+.tab-bar .tab-item text { font-size: $font-size-base; color: $text-color-secondary; }
+.tab-bar .tab-item.active text { color: $text-color-primary; font-weight: 600; }
+.tab-bar .tab-item .tab-line { position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 60rpx; height: 4rpx; background: $primary-color; border-radius: 2rpx; }
+.list-scroll { flex: 1; height: 0; }
+.card-set { padding: 20rpx 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); }
+.card-head { display: flex; justify-content: space-between; align-items: flex-start; }
+.card-left { flex: 1; min-width: 0; }
+.card-no { font-size: 28rpx; font-weight: 600; color: $text-color-primary; display: block; margin-bottom: 6rpx; }
+.card-time { font-size: 24rpx; color: $text-color-muted; display: block; }
+.card-amount { font-size: 32rpx; font-weight: 700; color: $primary-color-dark; margin: 0 20rpx; }
+.card-status { padding: 4rpx 14rpx; border-radius: 8rpx; font-size: 22rpx; font-weight: 500; flex-shrink: 0; }
+.card-status.pending { background: $warning-color-bg; color: $warning-color; }
+.card-status.approved { background: $success-color-bg; color: $success-color; }
+.card-status.rejected { background: $error-color-bg; color: $error-color; }
+.card-foot { margin-top: 14rpx; padding-top: 14rpx; border-top: 1rpx solid $border-color-light; }
+.foot-reason { font-size: 24rpx; color: $text-color-muted; }
+.load-tip { display: flex; justify-content: center; padding: 36rpx 0; }
+.dot-row { display: flex; gap: 8rpx; }
+.pulse-dot { width: 9rpx; height: 9rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite; }
+.pulse-dot:nth-child(2) { animation-delay: 0.2s; } .pulse-dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-muted; }
+.empty-state { display: flex; align-items: center; justify-content: center; padding: 100rpx 0; }
+.empty-text { font-size: 28rpx; color: $text-color-muted; }
+</style>

+ 51 - 0
haha-admin-mp/src/pages/products/master-library.vue

@@ -0,0 +1,51 @@
+<template>
+  <view class="page">
+    <NavBar title="商品总库" :showBack="true" />
+    <scroll-view class="list-scroll" scroll-y @scrolltolower="loadMore">
+      <view class="card-set">
+        <view class="card" v-for="item in list" :key="item.id">
+          <view class="card-head">
+            <view class="card-left">
+              <text class="card-name">{{ item.productName }}</text>
+              <view class="card-meta"><text class="meta-code">条码: {{ item.barcode }}</text><text class="meta-code" v-if="item.code">编码: {{ item.code }}</text></view>
+              <text class="card-adapt">适配: {{ item.deviceAdapt || '通用' }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+      <view class="load-tip" v-if="loading"><view class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view></view>
+      <view class="end-tip" v-if="!hasMore && list.length > 0"><text>没有更多了</text></view>
+      <view class="empty-state" v-if="!loading && list.length === 0"><text class="empty-text">暂无数据</text></view>
+    </scroll-view>
+  </view>
+</template>
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { getMasterProductList } from '@/api/masterProduct';
+import { DEFAULT_PAGE_SIZE } from '@/utils/constants';
+const list = ref<any[]>([]); const loading = ref(false); const hasMore = ref(true); const page = ref(1);
+const loadData = async () => { loading.value = true; try { const res = await getMasterProductList({ page: page.value, pageSize: DEFAULT_PAGE_SIZE }); if (page.value === 1) list.value = res.list || []; else list.value = [...list.value, ...(res.list || [])]; hasMore.value = list.value.length < (res.total || 0); } catch { } loading.value = false; };
+const loadMore = () => { if (loading.value || !hasMore.value) return; page.value++; loadData(); };
+onMounted(() => loadData());
+</script>
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom); }
+.list-scroll { flex: 1; height: 0; }
+.card-set { padding: 20rpx 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); }
+.card-head { display: flex; justify-content: space-between; align-items: flex-start; }
+.card-left { flex: 1; }
+.card-name { font-size: 30rpx; font-weight: 600; color: $text-color-primary; display: block; margin-bottom: 8rpx; }
+.card-meta { display: flex; gap: 16rpx; margin-bottom: 6rpx; }
+.meta-code { font-size: 22rpx; color: $text-color-muted; }
+.card-adapt { font-size: 22rpx; color: $text-color-muted; }
+.load-tip { display: flex; justify-content: center; padding: 36rpx 0; }
+.dot-row { display: flex; gap: 8rpx; }
+.pulse-dot { width: 9rpx; height: 9rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite; }
+.pulse-dot:nth-child(2) { animation-delay: 0.2s; } .pulse-dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-muted; }
+.empty-state { display: flex; align-items: center; justify-content: center; padding: 100rpx 0; }
+.empty-text { font-size: 28rpx; color: $text-color-muted; }
+</style>

+ 51 - 0
haha-admin-mp/src/pages/statistics/category.vue

@@ -0,0 +1,51 @@
+<template>
+  <view class="page">
+    <NavBar title="品类销售" :showBack="true" />
+    <scroll-view class="list-scroll" scroll-y v-if="data">
+      <view class="section"><text class="section-title">品类占比</text></view>
+      <view class="card-set">
+        <view class="card" v-for="item in data.categories" :key="item.name">
+          <view class="card-head">
+            <text class="card-name">{{ item.name }}</text>
+            <text class="card-amount">{{ formatMoney(item.amount || 0) }}</text>
+          </view>
+          <view class="progress-bar"><view class="progress-fill" :style="{ width: (item.ratio || 0) + '%' }"></view></view>
+          <text class="card-ratio">{{ (item.ratio || 0).toFixed(1) }}%</text>
+        </view>
+      </view>
+      <view class="end-tip"><text>-- 数据截至最近30天 --</text></view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { get } from '@/utils/request';
+import { formatMoney } from '@/utils/common';
+const data = ref<any>(null);
+const loadData = async () => {
+  try {
+    const end = new Date().toISOString().slice(0,10);
+    const start = new Date(Date.now()-30*86400000).toISOString().slice(0,10);
+    data.value = await get('/statistics/category/overview', { startDate: start, endDate: end });
+  } catch { }
+};
+onMounted(() => loadData());
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; }
+.list-scroll { flex: 1; height: 0; }
+.section { background: $bg-color-card; margin-bottom: $spacing-2; padding: $spacing-3 $spacing-4; }
+.section-title { font-size: 28rpx; font-weight: 600; color: $text-color-primary; }
+.card-set { padding: 0 24rpx 20rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 20rpx 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); }
+.card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
+.card-name { font-size: 28rpx; font-weight: 500; color: $text-color-primary; }
+.card-amount { font-size: 28rpx; font-weight: 600; color: $primary-color-dark; }
+.progress-bar { height: 12rpx; background: $bg-color-page; border-radius: 6rpx; overflow: hidden; margin-bottom: 6rpx; }
+.progress-fill { height: 100%; background: $primary-color; border-radius: 6rpx; }
+.card-ratio { font-size: 22rpx; color: $text-color-muted; }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-placeholder; }
+</style>

+ 67 - 0
haha-admin-mp/src/pages/statistics/device-sales.vue

@@ -0,0 +1,67 @@
+<template>
+  <view class="page">
+    <NavBar title="设备销售" :showBack="true" />
+    <scroll-view class="list-scroll" scroll-y @scrolltolower="loadMore">
+      <view class="card-set">
+        <view class="card" v-for="item in list" :key="item.deviceId">
+          <view class="card-head">
+            <view class="card-left">
+              <text class="card-name">{{ item.deviceName || item.deviceId }}</text>
+              <text class="card-shop" v-if="item.shopName">{{ item.shopName }}</text>
+            </view>
+            <text class="card-amount">{{ formatMoney(item.totalAmount || 0) }}</text>
+          </view>
+          <view class="card-foot">
+            <text class="foot-stat">订单 {{ item.orderCount || 0 }}</text>
+            <text class="foot-stat">利润 {{ formatMoney(item.profit || 0) }}</text>
+          </view>
+        </view>
+      </view>
+      <view class="load-tip" v-if="loading"><view class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view></view>
+      <view class="end-tip" v-if="!hasMore && list.length > 0"><text>没有更多了</text></view>
+      <view class="empty-state" v-if="!loading && list.length === 0"><text class="empty-text">暂无数据</text></view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { get } from '@/utils/request';
+import { formatMoney } from '@/utils/common';
+const list = ref<any[]>([]); const loading = ref(false); const hasMore = ref(true); const page = ref(1);
+const loadData = async () => {
+  loading.value = true;
+  try {
+    const end = new Date().toISOString().slice(0,10); const start = new Date(Date.now()-30*86400000).toISOString().slice(0,10);
+    const res = await get('/statistics/device/list', { page: page.value, pageSize: 20, startDate: start, endDate: end, sortBy: 'totalAmount', sortOrder: 'desc' });
+    if (page.value === 1) list.value = res.list || []; else list.value = [...list.value, ...(res.list || [])];
+    hasMore.value = list.value.length < (res.total || 0);
+  } catch { }
+  loading.value = false;
+};
+const loadMore = () => { if (loading.value || !hasMore.value) return; page.value++; loadData(); };
+onMounted(() => loadData());
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; }
+.list-scroll { flex: 1; height: 0; }
+.card-set { padding: 20rpx 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 20rpx 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); }
+.card-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12rpx; }
+.card-left { flex: 1; }
+.card-name { font-size: 30rpx; font-weight: 600; color: $text-color-primary; display: block; margin-bottom: 4rpx; }
+.card-shop { font-size: 24rpx; color: $text-color-muted; }
+.card-amount { font-size: 30rpx; font-weight: 700; color: $primary-color-dark; white-space: nowrap; }
+.card-foot { display: flex; gap: 24rpx; }
+.foot-stat { font-size: 22rpx; color: $text-color-muted; }
+.load-tip { display: flex; justify-content: center; padding: 36rpx 0; }
+.dot-row { display: flex; gap: 8rpx; }
+.pulse-dot { width: 9rpx; height: 9rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite; }
+.pulse-dot:nth-child(2) { animation-delay: 0.2s; } .pulse-dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-muted; }
+.empty-state { display: flex; align-items: center; justify-content: center; padding: 100rpx 0; }
+.empty-text { font-size: 28rpx; color: $text-color-muted; }
+</style>

+ 65 - 0
haha-admin-mp/src/pages/statistics/product-rank.vue

@@ -0,0 +1,65 @@
+<template>
+  <view class="page">
+    <NavBar title="商品排行" :showBack="true" />
+    <scroll-view class="list-scroll" scroll-y @scrolltolower="loadMore">
+      <view class="card-set">
+        <view class="card" v-for="(item, idx) in list" :key="item.productId">
+          <view class="card-head">
+            <view class="rank-badge" :class="'top' + Math.min(idx + 1, 3)"><text>{{ idx + 1 }}</text></view>
+            <view class="card-center">
+              <text class="card-name">{{ item.productName }}</text>
+              <text class="card-meta">销量 {{ item.salesCount || 0 }}</text>
+            </view>
+            <text class="card-amount">{{ formatMoney(item.salesAmount || 0) }}</text>
+          </view>
+        </view>
+      </view>
+      <view class="load-tip" v-if="loading"><view class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view></view>
+      <view class="end-tip" v-if="!hasMore && list.length > 0"><text>没有更多了</text></view>
+      <view class="empty-state" v-if="!loading && list.length === 0"><text class="empty-text">暂无数据</text></view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { get } from '@/utils/request';
+import { formatMoney } from '@/utils/common';
+const list = ref<any[]>([]); const loading = ref(false); const hasMore = ref(true); const page = ref(1);
+const loadData = async () => {
+  loading.value = true;
+  try {
+    const end = new Date().toISOString().slice(0,10); const start = new Date(Date.now()-30*86400000).toISOString().slice(0,10);
+    const res = await get('/statistics/product/list', { page: page.value, pageSize: 20, startDate: start, endDate: end, sortBy: 'salesAmount', sortOrder: 'desc' });
+    if (page.value === 1) list.value = res.list || []; else list.value = [...list.value, ...(res.list || [])];
+    hasMore.value = list.value.length < (res.total || 0);
+  } catch { }
+  loading.value = false;
+};
+const loadMore = () => { if (loading.value || !hasMore.value) return; page.value++; loadData(); };
+onMounted(() => loadData());
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; }
+.list-scroll { flex: 1; height: 0; }
+.card-set { padding: 20rpx 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 20rpx 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); display: flex; flex-direction: column; }
+.card-head { display: flex; align-items: center; }
+.rank-badge { width: 44rpx; height: 44rpx; border-radius: 10rpx; display: flex; align-items: center; justify-content: center; margin-right: 16rpx; flex-shrink: 0; }
+.rank-badge text { font-size: 24rpx; font-weight: 700; color: #fff; }
+.rank-badge.top1 { background: #f59e0b; } .rank-badge.top2 { background: #94a3b8; } .rank-badge.top3 { background: #cd853f; }
+.card-center { flex: 1; min-width: 0; }
+.card-name { font-size: 28rpx; font-weight: 500; color: $text-color-primary; display: block; margin-bottom: 4rpx; }
+.card-meta { font-size: 22rpx; color: $text-color-muted; }
+.card-amount { font-size: 28rpx; font-weight: 600; color: $primary-color-dark; white-space: nowrap; }
+.load-tip { display: flex; justify-content: center; padding: 36rpx 0; }
+.dot-row { display: flex; gap: 8rpx; }
+.pulse-dot { width: 9rpx; height: 9rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite; }
+.pulse-dot:nth-child(2) { animation-delay: 0.2s; } .pulse-dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-muted; }
+.empty-state { display: flex; align-items: center; justify-content: center; padding: 100rpx 0; }
+.empty-text { font-size: 28rpx; color: $text-color-muted; }
+</style>

+ 65 - 0
haha-admin-mp/src/pages/statistics/profit.vue

@@ -0,0 +1,65 @@
+<template>
+  <view class="page">
+    <NavBar title="利润报表" :showBack="true" />
+    <scroll-view class="list-scroll" scroll-y @scrolltolower="loadMore">
+      <view class="card-set">
+        <view class="card" v-for="item in list" :key="item.shopId">
+          <view class="card-head">
+            <view class="card-left">
+              <text class="card-name">{{ item.shopName }}</text>
+              <text class="card-meta">收入 {{ formatMoney(item.revenue || 0) }}</text>
+              <text class="card-meta">成本 {{ formatMoney(item.cost || 0) }}</text>
+            </view>
+            <text class="card-profit" :class="(item.profit || 0) >= 0 ? 'positive' : 'negative'">{{ formatMoney(item.profit || 0) }}</text>
+          </view>
+          <view class="card-foot">
+            <text class="foot-stat">毛利率 {{ (item.profitRate || 0).toFixed(1) }}%</text>
+          </view>
+        </view>
+      </view>
+      <view class="load-tip" v-if="loading"><view class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view></view>
+      <view class="end-tip" v-if="!hasMore && list.length > 0"><text>没有更多了</text></view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { get } from '@/utils/request';
+import { formatMoney } from '@/utils/common';
+const list = ref<any[]>([]); const loading = ref(false); const hasMore = ref(true); const page = ref(1);
+const loadData = async () => {
+  loading.value = true;
+  try {
+    const end = new Date().toISOString().slice(0,10); const start = new Date(Date.now()-30*86400000).toISOString().slice(0,10);
+    const res = await get('/statistics/profit/list', { page: page.value, pageSize: 20, startDate: start, endDate: end, sortBy: 'profit', sortOrder: 'desc' });
+    if (page.value === 1) list.value = res.list || []; else list.value = [...list.value, ...(res.list || [])];
+    hasMore.value = list.value.length < (res.total || 0);
+  } catch { }
+  loading.value = false;
+};
+const loadMore = () => { if (loading.value || !hasMore.value) return; page.value++; loadData(); };
+onMounted(() => loadData());
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; }
+.list-scroll { flex: 1; height: 0; }
+.card-set { padding: 20rpx 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 20rpx 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); }
+.card-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10rpx; }
+.card-left { display: flex; flex-direction: column; }
+.card-name { font-size: 28rpx; font-weight: 600; color: $text-color-primary; margin-bottom: 4rpx; }
+.card-meta { font-size: 22rpx; color: $text-color-muted; line-height: 1.5; }
+.card-profit { font-size: 32rpx; font-weight: 700; white-space: nowrap; }
+.card-profit.positive { color: $success-color; } .card-profit.negative { color: $error-color; }
+.card-foot { margin-top: 8rpx; }
+.foot-stat { font-size: 22rpx; color: $text-color-muted; }
+.load-tip { display: flex; justify-content: center; padding: 36rpx 0; }
+.dot-row { display: flex; gap: 8rpx; }
+.pulse-dot { width: 9rpx; height: 9rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite; }
+.pulse-dot:nth-child(2) { animation-delay: 0.2s; } .pulse-dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-muted; }
+</style>

+ 42 - 0
haha-admin-mp/src/pages/statistics/repurchase.vue

@@ -0,0 +1,42 @@
+<template>
+  <view class="page">
+    <NavBar title="复购统计" :showBack="true" />
+    <scroll-view class="list-scroll" scroll-y v-if="data">
+      <view class="section">
+        <text class="section-title">复购概况</text>
+        <view class="stats-grid">
+          <view class="stat-item"><text class="stat-value">{{ (data.repurchaseRate || 0).toFixed(1) }}%</text><text class="stat-label">复购率</text></view>
+          <view class="stat-item"><text class="stat-value">{{ data.repurchaseUsers || 0 }}</text><text class="stat-label">复购用户</text></view>
+          <view class="stat-item"><text class="stat-value">{{ data.totalUsers || 0 }}</text><text class="stat-label">总用户</text></view>
+        </view>
+      </view>
+      <view class="end-tip"><text>-- 数据截至最近30天 --</text></view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { get } from '@/utils/request';
+const data = ref<any>(null);
+const loadData = async () => {
+  try {
+    const end = new Date().toISOString().slice(0,10); const start = new Date(Date.now()-30*86400000).toISOString().slice(0,10);
+    data.value = await get('/statistics/repurchase/overview', { startDate: start, endDate: end });
+  } catch { }
+};
+onMounted(() => loadData());
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; }
+.list-scroll { flex: 1; height: 0; }
+.section { background: $bg-color-card; margin-bottom: $spacing-2; padding: $spacing-3 $spacing-4; }
+.section-title { display: block; font-size: 28rpx; font-weight: 600; color: $text-color-primary; margin-bottom: $spacing-3; padding-bottom: $spacing-2; border-bottom: 1rpx solid $border-color-light; }
+.stats-grid { display: flex; }
+.stat-item { flex: 1; display: flex; flex-direction: column; align-items: center; padding: $spacing-3 0; }
+.stat-value { font-size: $font-size-2xl; font-weight: 700; color: $text-color-primary; margin-bottom: $spacing-1; }
+.stat-label { font-size: 24rpx; color: $text-color-muted; }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-placeholder; }
+</style>

+ 68 - 0
haha-admin-mp/src/pages/statistics/shop-sales.vue

@@ -0,0 +1,68 @@
+<template>
+  <view class="page">
+    <NavBar title="门店销售" :showBack="true" />
+    <scroll-view class="list-scroll" scroll-y @scrolltolower="loadMore">
+      <view class="card-set">
+        <view class="card" v-for="(item, idx) in list" :key="item.shopId">
+          <view class="card-head">
+            <view class="rank-badge" :class="'top' + Math.min(idx + 1, 3)"><text>{{ idx + 1 }}</text></view>
+            <view class="card-center">
+              <text class="card-name">{{ item.shopName }}</text>
+              <text class="card-city" v-if="item.city">{{ item.city }}</text>
+            </view>
+            <text class="card-amount">{{ formatMoney(item.totalAmount || 0) }}</text>
+          </view>
+          <view class="card-foot">
+            <text class="foot-stat">订单 {{ item.orderCount || 0 }}</text>
+            <text class="foot-stat">利润 {{ formatMoney(item.profit || 0) }}</text>
+          </view>
+        </view>
+      </view>
+      <view class="load-tip" v-if="loading"><view class="dot-row"><view class="pulse-dot"></view><view class="pulse-dot"></view><view class="pulse-dot"></view></view></view>
+      <view class="end-tip" v-if="!hasMore && list.length > 0"><text>没有更多了</text></view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { get } from '@/utils/request';
+import { formatMoney } from '@/utils/common';
+const list = ref<any[]>([]); const loading = ref(false); const hasMore = ref(true); const page = ref(1);
+const loadData = async () => {
+  loading.value = true;
+  try {
+    const end = new Date().toISOString().slice(0,10); const start = new Date(Date.now()-30*86400000).toISOString().slice(0,10);
+    const res = await get('/statistics/shop/list', { page: page.value, pageSize: 20, startDate: start, endDate: end, sortBy: 'totalAmount', sortOrder: 'desc' });
+    if (page.value === 1) list.value = res.list || []; else list.value = [...list.value, ...(res.list || [])];
+    hasMore.value = list.value.length < (res.total || 0);
+  } catch { }
+  loading.value = false;
+};
+const loadMore = () => { if (loading.value || !hasMore.value) return; page.value++; loadData(); };
+onMounted(() => loadData());
+</script>
+
+<style lang="scss" scoped>
+.page { height: 100vh; background: $bg-color-page; display: flex; flex-direction: column; }
+.list-scroll { flex: 1; height: 0; }
+.card-set { padding: 20rpx 24rpx; display: flex; flex-direction: column; gap: 16rpx; }
+.card { background: $bg-color-card; border-radius: $radius-md; padding: 20rpx 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); }
+.card-head { display: flex; align-items: center; margin-bottom: 12rpx; }
+.rank-badge { width: 44rpx; height: 44rpx; border-radius: 10rpx; display: flex; align-items: center; justify-content: center; margin-right: 16rpx; flex-shrink: 0; }
+.rank-badge text { font-size: 24rpx; font-weight: 700; color: #fff; }
+.rank-badge.top1 { background: #f59e0b; } .rank-badge.top2 { background: #94a3b8; } .rank-badge.top3 { background: #cd853f; }
+.card-center { flex: 1; }
+.card-name { font-size: 28rpx; font-weight: 500; color: $text-color-primary; display: block; }
+.card-city { font-size: 22rpx; color: $text-color-muted; }
+.card-amount { font-size: 28rpx; font-weight: 700; color: $primary-color-dark; white-space: nowrap; }
+.card-foot { display: flex; gap: 24rpx; }
+.foot-stat { font-size: 22rpx; color: $text-color-muted; }
+.load-tip { display: flex; justify-content: center; padding: 36rpx 0; }
+.dot-row { display: flex; gap: 8rpx; }
+.pulse-dot { width: 9rpx; height: 9rpx; background: $text-color-placeholder; border-radius: 50%; animation: pulse 1.2s ease-in-out infinite; }
+.pulse-dot:nth-child(2) { animation-delay: 0.2s; } .pulse-dot:nth-child(3) { animation-delay: 0.4s; }
+@keyframes pulse { 0%,80%,100% { transform: scale(0.5); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
+.end-tip { text-align: center; padding: 36rpx; font-size: 24rpx; color: $text-color-muted; }
+</style>

+ 151 - 1
haha-admin-mp/src/utils/constants.ts

@@ -1,4 +1,4 @@
-/**
+/** 
  * 全局枚举常量定义
  * 从 mock 数据迁移而来,供运行时代码统一引用
  */
@@ -26,6 +26,45 @@ export const OrderStatusColor: Record<number, string> = {
   [OrderStatus.CLOSED]: '#64748b'
 };
 
+// ==================== 支付状态 ====================
+
+export const PayStatusText: Record<string, string> = {
+  UNPAID: '未支付',
+  PAID: '已支付',
+  REFUND: '已退款',
+  PARTIAL_REFUND: '部分退款'
+};
+
+// ==================== 活动状态 ====================
+
+export enum ActivityStatus {
+  DRAFT = 0,
+  PUBLISHED = 1,
+  PAUSED = 2,
+  ENDED = 3
+}
+
+export const ActivityStatusText: Record<number, string> = {
+  [ActivityStatus.DRAFT]: '草稿',
+  [ActivityStatus.PUBLISHED]: '进行中',
+  [ActivityStatus.PAUSED]: '已暂停',
+  [ActivityStatus.ENDED]: '已结束'
+};
+
+export const ActivityStatusColor: Record<number, string> = {
+  [ActivityStatus.DRAFT]: '#94a3b8',
+  [ActivityStatus.PUBLISHED]: '#10b981',
+  [ActivityStatus.PAUSED]: '#f59e0b',
+  [ActivityStatus.ENDED]: '#64748b'
+};
+
+export const ActivityTypeText: Record<number, string> = {
+  1: '首单立减',
+  2: '优惠折扣',
+  3: '商品满减',
+  4: '邀请有礼'
+};
+
 // ==================== 设备状态 ====================
 
 export enum DeviceStatus {
@@ -49,6 +88,24 @@ export const DeviceStatusColor: Record<number, string> = {
   [DeviceStatus.ERROR]: '#ef4444'
 };
 
+// ==================== 优惠券类型 ====================
+
+export enum CouponType {
+  FULL_REDUCTION = 1,
+  DISCOUNT = 2
+}
+
+export const CouponTypeText: Record<number, string> = {
+  [CouponType.FULL_REDUCTION]: '满减券',
+  [CouponType.DISCOUNT]: '折扣券'
+};
+
+export const CouponStatusText: Record<number, string> = {
+  0: '草稿',
+  1: '已上架',
+  2: '已下架'
+};
+
 // ==================== 商品状态 ====================
 
 export enum ProductStatus {
@@ -115,6 +172,99 @@ export const UserStatusText: Record<number, string> = {
   [UserStatus.ENABLED]: '正常'
 };
 
+// ==================== 退款状态 ====================
+
+export enum RefundStatus {
+  PENDING = 0,
+  APPROVED = 1,
+  REJECTED = 2,
+  COMPLETED = 3
+}
+
+export const RefundStatusText: Record<number, string> = {
+  [RefundStatus.PENDING]: '待审核',
+  [RefundStatus.APPROVED]: '已通过',
+  [RefundStatus.REJECTED]: '已驳回',
+  [RefundStatus.COMPLETED]: '已完成'
+};
+
+export const RefundStatusColor: Record<number, string> = {
+  [RefundStatus.PENDING]: '#f59e0b',
+  [RefundStatus.APPROVED]: '#10b981',
+  [RefundStatus.REJECTED]: '#ef4444',
+  [RefundStatus.COMPLETED]: '#0ea5e9'
+};
+
+// ==================== 补货单状态 ====================
+
+export enum ReplenishmentOrderStatus {
+  DRAFT = 0,
+  SUBMITTED = 1,
+  SYNCED = 2,
+  COMPLETED = 3,
+  CANCELLED = 4
+}
+
+export const ReplenishmentOrderStatusText: Record<number, string> = {
+  [ReplenishmentOrderStatus.DRAFT]: '草稿',
+  [ReplenishmentOrderStatus.SUBMITTED]: '已提交',
+  [ReplenishmentOrderStatus.SYNCED]: '已同步',
+  [ReplenishmentOrderStatus.COMPLETED]: '已完成',
+  [ReplenishmentOrderStatus.CANCELLED]: '已取消'
+};
+
+// ==================== 定时折扣状态 ====================
+
+export enum TimedDiscountStatus {
+  DRAFT = 0,
+  ACTIVE = 1,
+  PAUSED = 2,
+  ENDED = 3
+}
+
+export const TimedDiscountStatusText: Record<number, string> = {
+  [TimedDiscountStatus.DRAFT]: '草稿',
+  [TimedDiscountStatus.ACTIVE]: '进行中',
+  [TimedDiscountStatus.PAUSED]: '已暂停',
+  [TimedDiscountStatus.ENDED]: '已结束'
+};
+
+// ==================== 分销提现审核状态 ====================
+
+export enum DistWithdrawalStatus {
+  PENDING = 0,
+  APPROVED = 1,
+  REJECTED = 2,
+  PAID = 3
+}
+
+export const DistWithdrawalStatusText: Record<number, string> = {
+  [DistWithdrawalStatus.PENDING]: '审核中',
+  [DistWithdrawalStatus.APPROVED]: '已通过',
+  [DistWithdrawalStatus.REJECTED]: '已驳回',
+  [DistWithdrawalStatus.PAID]: '已打款'
+};
+
+// ==================== 公告类型 ====================
+
+export enum AnnouncementType {
+  SYSTEM = 1,
+  ACTIVITY = 2,
+  MAINTENANCE = 3
+}
+
+export const AnnouncementTypeText: Record<number, string> = {
+  [AnnouncementType.SYSTEM]: '系统公告',
+  [AnnouncementType.ACTIVITY]: '活动公告',
+  [AnnouncementType.MAINTENANCE]: '维护公告'
+};
+
+export const AnnouncementStatusText: Record<number, string> = {
+  0: '草稿',
+  1: '已发布',
+  2: '已下线'
+};
+
 // ==================== 通用分页参数 ====================
 
 export const DEFAULT_PAGE_SIZE = 10;