Jelajahi Sumber

运营端小程序页面开发、优化

skyline 2 minggu lalu
induk
melakukan
a0e78693f7

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

@@ -12,3 +12,6 @@ export * from './inventory';
 export * from './checkin';
 export * from './distribution';
 export * from './invite';
+export * from './newProductApply';
+export * from './staff';
+export * from './replenisher';

+ 76 - 0
haha-admin-mp/src/api/newProductApply.ts

@@ -0,0 +1,76 @@
+import { get, post, put, del } from '@/utils/request';
+
+export interface NewProductApplyQuery {
+  page?: number;
+  pageSize?: number;
+  productName?: string;
+  barcode?: string;
+  status?: number;
+  applicantId?: number;
+}
+
+export interface NewProductApplyItem {
+  id?: number;
+  productName: string;
+  barcode: string;
+  category?: string;
+  brand?: string;
+  specification?: string;
+  unit?: string;
+  price?: number;
+  costPrice?: number;
+  images?: string;
+  description?: string;
+  status?: number;
+  applicantId?: number;
+  applicantName?: string;
+  applyTime?: string;
+  auditStatus?: number;
+  auditRemark?: string;
+}
+
+export interface NewProductApplyStatistics {
+  total: number;
+  pending: number;
+  approved: number;
+  rejected: number;
+}
+
+export interface NewProductApplyListResponse {
+  list: NewProductApplyItem[];
+  total: number;
+  pageSize: number;
+  currentPage: number;
+}
+
+export async function getNewProductApplyList(params: NewProductApplyQuery = {}): Promise<NewProductApplyListResponse> {
+  return get('/new-product-apply/list', params);
+}
+
+export async function getNewProductApplyById(id: number): Promise<NewProductApplyItem> {
+  return get(`/new-product-apply/${id}`);
+}
+
+export async function getNewProductApplyStatistics(): Promise<NewProductApplyStatistics> {
+  return get('/new-product-apply/statistics');
+}
+
+export async function submitNewProductApply(data: NewProductApplyItem): Promise<any> {
+  return post('/new-product-apply/submit', data);
+}
+
+export async function saveNewProductApplyDraft(data: NewProductApplyItem): Promise<any> {
+  return post('/new-product-apply/save-draft', data);
+}
+
+export async function updateNewProductApply(id: number, data: NewProductApplyItem): Promise<any> {
+  return put(`/new-product-apply/${id}`, data);
+}
+
+export async function deleteNewProductApply(id: number): Promise<any> {
+  return del(`/new-product-apply/${id}`);
+}
+
+export async function checkBarcode(barcode: string): Promise<any> {
+  return get('/new-product-apply/check-barcode', { barcode });
+}

+ 94 - 0
haha-admin-mp/src/api/replenisher.ts

@@ -0,0 +1,94 @@
+/**
+ * 补货员管理 API(运营平台端)
+ */
+import { get, post, put, del } from '@/utils/request';
+
+export interface ReplenisherQuery {
+  page: number;
+  pageSize: number;
+  keyword?: string;
+  status?: number;
+}
+
+export interface ReplenisherForm {
+  id?: number;
+  name: string;
+  phone?: string;
+  employeeId?: string;
+  status?: number;
+}
+
+export interface ReplenisherListResponse {
+  list: any[];
+  total: number;
+}
+
+/**
+ * 分页查询补货员列表
+ */
+export function getReplenisherList(params: ReplenisherQuery): Promise<ReplenisherListResponse> {
+  return get('/replenishers/list', params);
+}
+
+/**
+ * 获取补货员详情
+ */
+export function getReplenisherById(id: number): Promise<any> {
+  return get(`/replenishers/${id}`);
+}
+
+/**
+ * 新增补货员
+ */
+export function createReplenisher(data: ReplenisherForm): Promise<void> {
+  return post('/replenishers', data);
+}
+
+/**
+ * 更新补货员
+ */
+export function updateReplenisher(id: number, data: Partial<ReplenisherForm>): Promise<void> {
+  return put(`/replenishers/${id}`, data);
+}
+
+/**
+ * 切换补货员状态
+ */
+export function updateReplenisherStatus(id: number, status: number): Promise<void> {
+  return put(`/replenishers/${id}/status`, { status });
+}
+
+/**
+ * 删除补货员
+ */
+export function deleteReplenisher(id: number): Promise<void> {
+  return del(`/replenishers/${id}`);
+}
+
+/**
+ * 获取补货员已绑定的设备列表
+ */
+export function getBoundDevices(id: number): Promise<string[]> {
+  return get(`/replenishers/${id}/devices`);
+}
+
+/**
+ * 绑定设备
+ */
+export function bindDevices(id: number, deviceIds: string[]): Promise<void> {
+  return post(`/replenishers/${id}/devices`, { deviceIds });
+}
+
+/**
+ * 解绑设备
+ */
+export function unbindDevice(replenisherId: number, deviceId: string): Promise<void> {
+  return del(`/replenishers/${replenisherId}/devices/${deviceId}`);
+}
+
+/**
+ * 生成微信绑定码
+ */
+export function generateBindingCode(id: number): Promise<{ bindingCode: string }> {
+  return post(`/replenishers/${id}/binding-code`);
+}

+ 87 - 0
haha-admin-mp/src/api/staff.ts

@@ -0,0 +1,87 @@
+/**
+ * 人员管理 API
+ */
+import { get, post, del } from '@/utils/request';
+
+export interface StaffQuery {
+  page: number;
+  pageSize: number;
+  username?: string;
+  phone?: string;
+  roleId?: number;
+  status?: number;
+}
+
+export interface StaffForm {
+  id?: number;
+  username: string;
+  password?: string;
+  realName: string;
+  phone: string;
+  email?: string;
+  roleIds?: string;
+  status?: number;
+  remark?: string;
+}
+
+export interface RoleOption {
+  id: number;
+  name: string;
+  code: string;
+}
+
+/**
+ * 分页查询管理员列表
+ */
+export function getStaffList(params: StaffQuery): Promise<{ list: any[]; total: number }> {
+  return post('/user', params);
+}
+
+/**
+ * 获取管理员详情
+ */
+export function getStaffById(id: number): Promise<any> {
+  return get(`/user/${id}`);
+}
+
+/**
+ * 新增管理员
+ */
+export function addStaff(data: StaffForm): Promise<void> {
+  return post('/user/add', data);
+}
+
+/**
+ * 更新管理员
+ */
+export function updateStaff(data: StaffForm): Promise<void> {
+  return post('/user/update', data);
+}
+
+/**
+ * 删除管理员
+ */
+export function deleteStaff(id: number): Promise<void> {
+  return del(`/user/${id}`);
+}
+
+/**
+ * 重置密码
+ */
+export function resetStaffPassword(id: number, newPassword: string): Promise<void> {
+  return post('/user/reset-password', { id, newPassword });
+}
+
+/**
+ * 获取管理员统计
+ */
+export function getStaffStatistics(): Promise<{ total: number; active: number; disabled: number; todayLogin: number }> {
+  return get('/user/statistics');
+}
+
+/**
+ * 获取所有角色(下拉用)
+ */
+export function getAllRoles(): Promise<RoleOption[]> {
+  return get('/role/list-all-role');
+}

+ 349 - 268
haha-admin-mp/src/pages.json

@@ -1,270 +1,351 @@
 {
-	"pages": [
-		{
-			"path": "pages/login/login",
-			"style": {
-				"navigationBarTitleText": "登录",
-				"navigationBarBackgroundColor": "#f8fafc",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/index/index",
-			"style": {
-				"navigationBarTitleText": "工作台",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/orders/list",
-			"style": {
-				"navigationBarTitleText": "订单管理",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/orders/detail",
-			"style": {
-				"navigationBarTitleText": "订单详情",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/device/list",
-			"style": {
-				"navigationBarTitleText": "设备管理",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/device/detail",
-			"style": {
-				"navigationBarTitleText": "设备详情",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/shop/list",
-			"style": {
-				"navigationBarTitleText": "门店管理",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/shop/detail",
-			"style": {
-				"navigationBarTitleText": "门店详情",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/products/list",
-			"style": {
-				"navigationBarTitleText": "配置上架商品",
-				"navigationBarBackgroundColor": "#FFC107",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/inventory/query",
-			"style": {
-				"navigationBarTitleText": "库存查询",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/inventory/warning",
-			"style": {
-				"navigationBarTitleText": "库存预警",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/customer/list",
-			"style": {
-				"navigationBarTitleText": "客户管理",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/customer/detail",
-			"style": {
-				"navigationBarTitleText": "客户详情",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/statistics/overview",
-			"style": {
-				"navigationBarTitleText": "数据统计",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/my/my",
-			"style": {
-				"navigationBarTitleText": "我的",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/checkin/index",
-			"style": {
-				"navigationBarTitleText": "签到打卡",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/checkin/records",
-			"style": {
-				"navigationBarTitleText": "签到记录",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/invite/index",
-			"style": {
-				"navigationBarTitleText": "邀请好友",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/invite/records",
-			"style": {
-				"navigationBarTitleText": "我的邀请",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/distribution/index",
-			"style": {
-				"navigationBarTitleText": "分销中心",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/distribution/withdrawal",
-			"style": {
-				"navigationBarTitleText": "申请提现",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/distribution/records",
-			"style": {
-				"navigationBarTitleText": "提现记录",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/replenish/index",
-			"style": {
-				"navigationBarTitleText": "补货首页",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/replenish/operation",
-			"style": {
-				"navigationBarTitleText": "补货操作",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		},
-		{
-			"path": "pages/replenish/bind",
-			"style": {
-				"navigationBarTitleText": "绑定微信",
-				"navigationBarBackgroundColor": "#ffffff",
-				"navigationBarTextStyle": "black",
-				"navigationStyle": "custom"
-			}
-		}
-	],
-	"globalStyle": {
-		"navigationBarTextStyle": "black",
-		"navigationBarTitleText": "哈哈运营平台",
-		"navigationBarBackgroundColor": "#ffffff",
-		"backgroundColor": "#f8fafc"
-	},
-	"tabBar": {
-		"custom": true,
-		"color": "#94a3b8",
-		"selectedColor": "#FFC107",
-		"backgroundColor": "#ffffff",
-		"borderStyle": "white",
-		"height": "50px",
-		"fontSize": "10px",
-		"list": [
-			{
-				"pagePath": "pages/index/index",
-				"text": "工作台",
-				"iconPath": "static/tabbar/work.png",
-				"selectedIconPath": "static/tabbar/work-active.png"
-			},
-			{
-				"pagePath": "pages/orders/list",
-				"text": "订单",
-				"iconPath": "static/tabbar/order.png",
-				"selectedIconPath": "static/tabbar/order-active.png"
-			},
-			{
-				"pagePath": "pages/device/list",
-				"text": "设备",
-				"iconPath": "static/tabbar/device.png",
-				"selectedIconPath": "static/tabbar/device-active.png"
-			},
-			{
-				"pagePath": "pages/my/my",
-				"text": "我的",
-				"iconPath": "static/tabbar/my.png",
-				"selectedIconPath": "static/tabbar/my-active.png"
-			}
-		]
-	}
+  "pages": [
+    {
+      "path": "pages/login/login",
+      "style": {
+        "navigationBarTitleText": "登录",
+        "navigationBarBackgroundColor": "#f8fafc",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/index/index",
+      "style": {
+        "navigationBarTitleText": "工作台",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/orders/list",
+      "style": {
+        "navigationBarTitleText": "订单管理",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/orders/detail",
+      "style": {
+        "navigationBarTitleText": "订单详情",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/device/list",
+      "style": {
+        "navigationBarTitleText": "设备管理",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/device/detail",
+      "style": {
+        "navigationBarTitleText": "设备详情",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/shop/list",
+      "style": {
+        "navigationBarTitleText": "门店管理",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/shop/detail",
+      "style": {
+        "navigationBarTitleText": "门店详情",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/products/list",
+      "style": {
+        "navigationBarTitleText": "配置上架商品",
+        "navigationBarBackgroundColor": "#FFC107",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/products/apply",
+      "style": {
+        "navigationBarTitleText": "新品申请",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/products/apply-form",
+      "style": {
+        "navigationBarTitleText": "新品申请",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/products/apply-detail",
+      "style": {
+        "navigationBarTitleText": "申请详情",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/inventory/query",
+      "style": {
+        "navigationBarTitleText": "库存查询",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/inventory/warning",
+      "style": {
+        "navigationBarTitleText": "库存预警",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/customer/list",
+      "style": {
+        "navigationBarTitleText": "客户管理",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/customer/detail",
+      "style": {
+        "navigationBarTitleText": "客户详情",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/statistics/overview",
+      "style": {
+        "navigationBarTitleText": "数据统计",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/my/my",
+      "style": {
+        "navigationBarTitleText": "我的",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/checkin/index",
+      "style": {
+        "navigationBarTitleText": "签到打卡",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/checkin/records",
+      "style": {
+        "navigationBarTitleText": "签到记录",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/invite/index",
+      "style": {
+        "navigationBarTitleText": "邀请好友",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/invite/records",
+      "style": {
+        "navigationBarTitleText": "我的邀请",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/distribution/index",
+      "style": {
+        "navigationBarTitleText": "分销中心",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/distribution/withdrawal",
+      "style": {
+        "navigationBarTitleText": "申请提现",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/distribution/records",
+      "style": {
+        "navigationBarTitleText": "提现记录",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/replenish/index",
+      "style": {
+        "navigationBarTitleText": "补货首页",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/replenish/operation",
+      "style": {
+        "navigationBarTitleText": "补货操作",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "path": "pages/replenish/bind",
+      "style": {
+        "navigationBarTitleText": "绑定微信",
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationStyle": "custom"
+      }
+    },
+    {
+      "style": {
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationBarTitleText": "公告中心",
+        "navigationStyle": "custom"
+      },
+      "path": "pages/announcement/list"
+    },
+    {
+      "style": {
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationBarTitleText": "人员管理",
+        "navigationStyle": "custom"
+      },
+      "path": "pages/staff/list"
+    },
+    {
+      "style": {
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationBarTitleText": "编辑人员",
+        "navigationStyle": "custom"
+      },
+      "path": "pages/staff/form"
+    },
+    {
+      "style": {
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationBarTitleText": "补货员管理",
+        "navigationStyle": "custom"
+      },
+      "path": "pages/replenisher/list"
+    },
+    {
+      "style": {
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationBarTitleText": "编辑补货员",
+        "navigationStyle": "custom"
+      },
+      "path": "pages/replenisher/form"
+    },
+    {
+      "style": {
+        "navigationBarBackgroundColor": "#ffffff",
+        "navigationBarTextStyle": "black",
+        "navigationBarTitleText": "设备绑定",
+        "navigationStyle": "custom"
+      },
+      "path": "pages/replenisher/bind-device"
+    }
+  ],
+  "globalStyle": {
+    "navigationBarTextStyle": "black",
+    "navigationBarTitleText": "哈哈运营平台",
+    "navigationBarBackgroundColor": "#ffffff",
+    "backgroundColor": "#f8fafc"
+  },
+  "tabBar": {
+    "custom": true,
+    "color": "#94a3b8",
+    "selectedColor": "#FFC107",
+    "backgroundColor": "#ffffff",
+    "borderStyle": "white",
+    "height": "50px",
+    "fontSize": "10px",
+    "list": [
+      {
+        "pagePath": "pages/index/index",
+        "text": "工作台",
+        "iconPath": "static/tabbar/work.png",
+        "selectedIconPath": "static/tabbar/work-active.png"
+      },
+      {
+        "pagePath": "pages/orders/list",
+        "text": "订单",
+        "iconPath": "static/tabbar/order.png",
+        "selectedIconPath": "static/tabbar/order-active.png"
+      },
+      {
+        "pagePath": "pages/device/list",
+        "text": "设备",
+        "iconPath": "static/tabbar/device.png",
+        "selectedIconPath": "static/tabbar/device-active.png"
+      },
+      {
+        "pagePath": "pages/my/my",
+        "text": "我的",
+        "iconPath": "static/tabbar/my.png",
+        "selectedIconPath": "static/tabbar/my-active.png"
+      }
+    ]
+  }
 }

+ 741 - 0
haha-admin-mp/src/pages/announcement/list.vue

@@ -0,0 +1,741 @@
+<template>
+  <view class="page">
+    <NavBar title="公告中心" :showBack="true" @back="goBack" />
+
+    <!-- 顶部分类区 -->
+    <view class="hero">
+      <view class="hero-row">
+        <view class="bell-icon"></view>
+        <view class="hero-text">
+          <text class="hero-title">公告中心</text>
+          <text class="hero-count">共 {{ total }} 条</text>
+        </view>
+      </view>
+
+      <scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
+        <view class="filter-row">
+          <view
+            class="filter-chip"
+            :class="{ on: currentType === 0 }"
+            @click="changeType(0)"
+          >
+            <text>全部</text>
+          </view>
+          <view
+            class="filter-chip"
+            :class="{ on: currentType === 1 }"
+            @click="changeType(1)"
+          >
+            <text>系统</text>
+          </view>
+          <view
+            class="filter-chip"
+            :class="{ on: currentType === 2 }"
+            @click="changeType(2)"
+          >
+            <text>活动</text>
+          </view>
+          <view
+            class="filter-chip"
+            :class="{ on: currentType === 3 }"
+            @click="changeType(3)"
+          >
+            <text>维护</text>
+          </view>
+        </view>
+      </scroll-view>
+    </view>
+
+    <!-- 列表 -->
+    <scroll-view
+      class="list-scroll"
+      scroll-y
+      @scrolltolower="loadMore"
+    >
+      <!-- 骨架屏 -->
+      <view class="skeleton-set" v-if="loading && list.length === 0">
+        <view class="sk-card" v-for="i in 4" :key="i">
+          <view class="sk-top">
+            <view class="sk-title"></view>
+            <view class="sk-chevron"></view>
+          </view>
+          <view class="sk-meta"></view>
+        </view>
+      </view>
+
+      <view class="card-set" v-else>
+        <view
+          class="card"
+          v-for="item in filteredList"
+          :key="item.id"
+          @click="toggleExpand(item)"
+        >
+          <!-- 头部 -->
+          <view class="card-top">
+            <view class="card-left">
+              <view class="pin" v-if="item.isTop === 1">
+                <text>置顶</text>
+              </view>
+              <text class="card-title">{{ item.title }}</text>
+            </view>
+            <view class="chevron" :class="{ open: expandedId === item.id }">
+              <view class="chevron-inner"></view>
+            </view>
+          </view>
+
+          <!-- 元信息 -->
+          <view class="card-meta">
+            <view class="dot" :class="'dot-' + item.type"></view>
+            <view class="badge" :class="'badge-' + item.type">
+              <text>{{ typeMap[item.type] || '公告' }}</text>
+            </view>
+            <text class="meta-time">{{ formatTime(item.publishTime) }}</text>
+            <text class="meta-read">{{ item.readCount || 0 }} 次阅读</text>
+          </view>
+
+          <!-- 展开内容 -->
+          <view class="card-body" v-if="expandedId === item.id">
+            <view class="body-rule"></view>
+            <text class="body-text">{{ item.content || '暂无内容' }}</text>
+            <image
+              v-if="item.coverImage"
+              :src="item.coverImage"
+              mode="widthFix"
+              class="body-image"
+            />
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view class="load-tip" v-if="loading && list.length > 0">
+        <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">
+        <view class="end-line"></view>
+        <text class="end-text">没有更多了</text>
+        <view class="end-line"></view>
+      </view>
+
+      <!-- 空状态 -->
+      <view class="empty" v-if="!loading && list.length === 0">
+        <view class="empty-icon">
+          <view class="doc">
+            <view class="doc-body">
+              <view class="doc-line" v-for="j in 3" :key="j"></view>
+            </view>
+            <view class="doc-fold"></view>
+          </view>
+        </view>
+        <text class="empty-title">暂无公告</text>
+        <text class="empty-desc">运营团队发布公告后将在这里展示</text>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { get } from '@/utils/request';
+
+interface AnnouncementItem {
+  id: string;
+  title: string;
+  content: string;
+  type: number;
+  coverImage?: string;
+  isTop: number;
+  status: number;
+  publishTime: string;
+  readCount: number;
+}
+
+const list = ref<AnnouncementItem[]>([]);
+const loading = ref(false);
+const expandedId = ref('');
+const page = ref(1);
+const total = ref(0);
+const currentType = ref(0);
+const pageSize = 10;
+const hasMore = ref(true);
+
+const typeMap: Record<number, string> = {
+  1: '系统公告',
+  2: '活动公告',
+  3: '维护公告',
+  4: '其他'
+};
+
+const filteredList = computed(() => {
+  if (currentType.value === 0) return list.value;
+  return list.value.filter(item => item.type === currentType.value);
+});
+
+const formatTime = (time: string) => {
+  if (!time) return '';
+  return time.substring(0, 16);
+};
+
+const goBack = () => {
+  uni.navigateBack();
+};
+
+const toggleExpand = (item: AnnouncementItem) => {
+  expandedId.value = expandedId.value === item.id ? '' : item.id;
+};
+
+const changeType = (type: number) => {
+  if (currentType.value === type) return;
+  currentType.value = type;
+  page.value = 1;
+  expandedId.value = '';
+  hasMore.value = true;
+  list.value = [];
+  fetchList();
+};
+
+const fetchList = async (isLoadMore = false) => {
+  if (loading.value) return;
+  loading.value = true;
+
+  try {
+    const params: any = { status: 1, page: page.value, pageSize };
+    if (currentType.value !== 0) {
+      params.type = currentType.value;
+    }
+    const data: any = await get('/announcement/list', params);
+
+    const newList = data?.list || [];
+    total.value = data?.total || 0;
+
+    if (isLoadMore) {
+      list.value = [...list.value, ...newList];
+    } else {
+      list.value = newList;
+    }
+    hasMore.value = list.value.length < total.value;
+  } catch (e) {
+    // ignore
+  } finally {
+    loading.value = false;
+  }
+};
+
+const loadMore = () => {
+  if (!hasMore.value || loading.value) return;
+  page.value++;
+  fetchList(true);
+};
+
+onMounted(() => {
+  fetchList();
+});
+</script>
+
+<style lang="scss" scoped>
+// ====== Design tokens (from DESIGN.md) ======
+$ink: #2C2C2C;
+$ash: #8C8C8C;
+$cloud: #BDBDBD;
+$white: #FFFFFF;
+$cream: #FAFAFA;
+$mist: #F5F5F5;
+$border: #EEEEEE;
+
+$yellow: #FFC107;
+$yellow-light: #FFE082;
+$yellow-dark: #FFA000;
+$yellow-bg: #FFF8E1;
+
+$green: #4CAF50;
+$green-bg: #F1F8E9;
+$orange: #FF9800;
+$orange-bg: #FFF3E0;
+$blue: #2196F3;
+$blue-bg: #E3F2FD;
+$gray: #9E9E9E;
+$gray-bg: #FAFAFA;
+
+$font-stack: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', system-ui, sans-serif;
+
+// ====== Page ======
+.page {
+  min-height: 100vh;
+  background: $cream;
+  font-family: $font-stack;
+  width: 100vw;
+  overflow-x: hidden;
+}
+
+// ====== Hero ======
+.hero {
+  padding: 32rpx 32rpx 0;
+}
+
+.hero-row {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+  margin-bottom: 24rpx;
+}
+
+.bell-icon {
+  width: 48rpx;
+  height: 48rpx;
+  border-radius: 50%;
+  background: $yellow-bg;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  position: relative;
+
+  &::before {
+    content: '';
+    width: 18rpx;
+    height: 14rpx;
+    background: $yellow;
+    border-radius: 9rpx 9rpx 2rpx 2rpx;
+  }
+  &::after {
+    content: '';
+    position: absolute;
+    width: 6rpx;
+    height: 6rpx;
+    background: $yellow;
+    border-radius: 50%;
+    top: 28rpx;
+  }
+}
+
+.hero-text {
+  display: flex;
+  flex-direction: column;
+  gap: 2rpx;
+}
+
+.hero-title {
+  font-size: 40rpx;
+  font-weight: 700;
+  color: $ink;
+  line-height: 1.2;
+  letter-spacing: -0.3px;
+}
+
+.hero-count {
+  font-size: 26rpx;
+  color: $ash;
+  letter-spacing: 0.2px;
+}
+
+// ====== Filter ======
+.filter-scroll {
+  white-space: nowrap;
+  width: 100%;
+}
+
+.filter-row {
+  display: flex;
+  gap: 12rpx;
+  padding-bottom: 8rpx;
+  padding-right: 32rpx;
+}
+
+.filter-chip {
+  padding: 12rpx 28rpx;
+  border-radius: 40rpx;
+  background: $white;
+  border: 1rpx solid $border;
+  display: inline-block;
+  flex-shrink: 0;
+  transition: background 0.2s ease, border-color 0.2s ease;
+
+  text {
+    font-size: 26rpx;
+    font-weight: 500;
+    color: $ash;
+    transition: color 0.2s ease;
+  }
+
+  &.on {
+    background: $yellow;
+    border-color: $yellow;
+
+    text {
+      color: $ink;
+      font-weight: 600;
+    }
+  }
+}
+
+// ====== List ======
+.list-scroll {
+  height: calc(100vh - 260rpx);
+  padding: 16rpx 28rpx 48rpx;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+// ====== Skeleton ======
+.skeleton-set {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.sk-card {
+  background: $white;
+  border-radius: 24rpx;
+  padding: 32rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+  animation: shimmer 1.5s infinite;
+}
+
+.sk-top {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 16rpx;
+}
+
+.sk-title {
+  flex: 1;
+  height: 34rpx;
+  background: linear-gradient(90deg, $mist 25%, #E8E8E8 50%, $mist 75%);
+  background-size: 200% 100%;
+  border-radius: 6rpx;
+  animation: shimmer-bar 1.5s infinite;
+}
+
+.sk-chevron {
+  width: 40rpx;
+  height: 40rpx;
+  border-radius: 50%;
+  background: linear-gradient(90deg, $mist 25%, #E8E8E8 50%, $mist 75%);
+  background-size: 200% 100%;
+  animation: shimmer-bar 1.5s infinite;
+  flex-shrink: 0;
+}
+
+.sk-meta {
+  width: 45%;
+  height: 26rpx;
+  margin-top: 20rpx;
+  background: linear-gradient(90deg, $mist 25%, #E8E8E8 50%, $mist 75%);
+  background-size: 200% 100%;
+  border-radius: 5rpx;
+  animation: shimmer-bar 1.5s infinite;
+}
+
+@keyframes shimmer-bar {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+
+// ====== Cards ======
+.card-set {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.card {
+  background: $white;
+  border-radius: 24rpx;
+  padding: 32rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+  transition: opacity 0.15s ease;
+  overflow: hidden;
+
+  &:active {
+    opacity: 0.7;
+  }
+}
+
+// Card header
+.card-top {
+  display: flex;
+  align-items: flex-start;
+  gap: 16rpx;
+}
+
+.card-left {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: flex-start;
+  gap: 12rpx;
+}
+
+.pin {
+  padding: 4rpx 12rpx;
+  background: $yellow-bg;
+  border-radius: 6rpx;
+  margin-top: 2rpx;
+  flex-shrink: 0;
+
+  text {
+    font-size: 20rpx;
+    font-weight: 700;
+    color: $yellow-dark;
+    letter-spacing: 0.3px;
+  }
+}
+
+.card-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: $ink;
+  line-height: 1.5;
+  letter-spacing: -0.1px;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+// Chevron
+.chevron {
+  width: 40rpx;
+  height: 40rpx;
+  border-radius: 50%;
+  background: $mist;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  margin-top: 2rpx;
+  transition: background 0.2s ease;
+}
+
+.chevron-inner {
+  width: 12rpx;
+  height: 12rpx;
+  border-right: 2rpx solid $cloud;
+  border-bottom: 2rpx solid $cloud;
+  transform: rotate(45deg);
+  margin-top: -4rpx;
+  transition: transform 0.2s ease, margin-top 0.2s ease;
+}
+
+.chevron.open .chevron-inner {
+  transform: rotate(-135deg);
+  margin-top: 4rpx;
+}
+
+// Meta row
+.card-meta {
+  display: flex;
+  align-items: center;
+  margin-top: 20rpx;
+  gap: 10rpx;
+}
+
+.dot {
+  width: 10rpx;
+  height: 10rpx;
+  border-radius: 50%;
+  flex-shrink: 0;
+
+  &.dot-1 { background: $blue; }
+  &.dot-2 { background: $green; }
+  &.dot-3 { background: $orange; }
+  &.dot-4 { background: $gray; }
+}
+
+.badge {
+  padding: 4rpx 14rpx;
+  border-radius: 6rpx;
+  flex-shrink: 0;
+
+  text {
+    font-size: 22rpx;
+    font-weight: 500;
+  }
+
+  &.badge-1 { background: $blue-bg; text { color: $blue; } }
+  &.badge-2 { background: $green-bg; text { color: $green; } }
+  &.badge-3 { background: $orange-bg; text { color: $orange; } }
+  &.badge-4 { background: $gray-bg; text { color: $gray; } }
+}
+
+.meta-time {
+  font-size: 24rpx;
+  color: $cloud;
+  letter-spacing: 0.1px;
+  flex-shrink: 0;
+}
+
+.meta-read {
+  font-size: 24rpx;
+  color: #C0C0C0;
+  margin-left: auto;
+  flex-shrink: 0;
+  white-space: nowrap;
+}
+
+// Expanded body
+.card-body {
+  margin-top: 24rpx;
+  animation: reveal 0.2s ease-out;
+}
+
+@keyframes reveal {
+  from {
+    opacity: 0;
+    transform: translateY(-8rpx);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.body-rule {
+  height: 1rpx;
+  background: $border;
+  margin-bottom: 20rpx;
+}
+
+.body-text {
+  font-size: 28rpx;
+  color: #555;
+  line-height: 1.75;
+  letter-spacing: 0.1px;
+  white-space: pre-wrap;
+}
+
+.body-image {
+  margin-top: 16rpx;
+  width: 100%;
+  border-radius: 16rpx;
+}
+
+// ====== Load more ======
+.load-tip {
+  display: flex;
+  justify-content: center;
+  padding: 40rpx 0 24rpx;
+}
+
+.dot-row {
+  display: flex;
+  gap: 8rpx;
+}
+
+.pulse-dot {
+  width: 10rpx;
+  height: 10rpx;
+  background: $cloud;
+  border-radius: 50%;
+  animation: pulse 1.2s ease-in-out infinite;
+
+  &:nth-child(2) { animation-delay: 0.2s; }
+  &: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 ======
+.end-tip {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 16rpx;
+  padding: 40rpx 0 24rpx;
+}
+
+.end-line {
+  width: 48rpx;
+  height: 1rpx;
+  background: $border;
+}
+
+.end-text {
+  font-size: 24rpx;
+  color: $cloud;
+}
+
+// ====== Empty ======
+.empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 120rpx 0 80rpx;
+  gap: 24rpx;
+}
+
+.empty-icon {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 50%;
+  background: $mist;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.doc {
+  position: relative;
+  width: 48rpx;
+  height: 56rpx;
+}
+
+.doc-body {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 42rpx;
+  height: 54rpx;
+  background: $cloud;
+  border-radius: 5rpx;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  gap: 6rpx;
+  padding: 10rpx 8rpx;
+}
+
+.doc-line {
+  width: 20rpx;
+  height: 3rpx;
+  background: $cream;
+  border-radius: 2rpx;
+
+  &:first-child { width: 14rpx; }
+  &:last-child { width: 10rpx; }
+}
+
+.doc-fold {
+  position: absolute;
+  right: 0;
+  top: 0;
+  width: 0;
+  height: 0;
+  border-left: 10rpx solid #D0D0D0;
+  border-bottom: 10rpx solid transparent;
+}
+
+.empty-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: $ash;
+}
+
+.empty-desc {
+  font-size: 26rpx;
+  color: $cloud;
+  letter-spacing: 0.2px;
+}
+</style>

+ 401 - 397
haha-admin-mp/src/pages/device/list.vue

@@ -1,13 +1,12 @@
 <template>
   <view class="page">
-    <!-- 黄色头部区域 -->
-    <view class="header-section" :class="{ 'has-shadow': isScrolled }">
+    <!-- 顶部 -->
+    <view class="header">
       <view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
       <view class="header-title">
         <text class="title-text">设备</text>
       </view>
 
-      <!-- Tab 切换 -->
       <view class="tab-bar">
         <view
           class="tab-item"
@@ -15,7 +14,7 @@
           @click="switchTab('all')"
         >
           <text class="tab-text">全部设备</text>
-          <view class="tab-indicator" v-if="activeTab === 'all'"></view>
+          <view class="tab-line" v-if="activeTab === 'all'"></view>
         </view>
         <view
           class="tab-item"
@@ -23,7 +22,7 @@
           @click="switchTab('online')"
         >
           <text class="tab-text">在线设备</text>
-          <view class="tab-indicator" v-if="activeTab === 'online'"></view>
+          <view class="tab-line" v-if="activeTab === 'online'"></view>
         </view>
         <view
           class="tab-item"
@@ -31,23 +30,23 @@
           @click="switchTab('offline')"
         >
           <text class="tab-text">离线设备</text>
-          <view class="tab-indicator" v-if="activeTab === 'offline'"></view>
+          <view class="tab-line" v-if="activeTab === 'offline'"></view>
         </view>
       </view>
     </view>
 
-    <!-- 搜索区域 -->
-    <view class="search-section">
+    <!-- 搜索 -->
+    <view class="search-bar">
       <view class="search-box">
         <view class="search-icon">
-          <view class="search-icon-circle"></view>
-          <view class="search-icon-line"></view>
+          <view class="si-ring"></view>
+          <view class="si-stem"></view>
         </view>
         <input
           class="search-input"
           v-model="keyword"
-          placeholder="请输入设备编号、设备名称、门店名称"
-          placeholder-class="search-placeholder"
+          placeholder="设备编号、名称、门店"
+          placeholder-class="search-ph"
           confirm-type="search"
           @confirm="handleSearch"
         />
@@ -57,110 +56,119 @@
       </view>
     </view>
 
-    <!-- 统计与操作栏 -->
-    <view class="stats-bar">
-      <text class="stats-total">共{{ totalCount }}条</text>
-      <view class="stats-refresh" @click="refreshNetwork">
-        <text class="refresh-text">如设备离线,可以点此尝试</text>
-        <text class="refresh-link">刷新网络</text>
+    <!-- 统计操作栏 -->
+    <view class="toolbar">
+      <text class="tb-count">共 {{ totalCount }} 条</text>
+      <view class="tb-refresh" @click="refreshNetwork">
+        <text class="tb-refresh-text">刷新网络</text>
       </view>
-      <view class="stats-filter" @click="showFilter">
-        <text class="filter-text">筛选</text>
-        <view class="filter-icon"></view>
+      <view class="tb-filter" @click="showFilter">
+        <text class="tb-filter-text">筛选</text>
+        <view class="tb-filter-arrow"></view>
       </view>
     </view>
 
-    <!-- 设备列表 -->
+    <!-- 列表 -->
     <scroll-view
-      class="device-scroll"
+      class="list-scroll"
       scroll-y
       @scrolltolower="loadMore"
-      @scroll="onScroll"
       refresher-enabled
       :refresher-triggered="refreshing"
       @refresherrefresh="onRefresh"
     >
-      <view class="device-list">
+      <view class="card-set">
         <view
-          class="device-card"
+          class="card"
           v-for="device in deviceList"
           :key="device.id"
           @click="goDetail(device.id)"
         >
-          <!-- 设备头部:状态 + 名称 -->
-          <view class="card-header">
-            <view class="status-badge" :class="getStatusClass(device.status)">
+          <!-- 头部:状态 + 信息 -->
+          <view class="card-head">
+            <view class="status-chip" :class="statusClass(device.status)">
               <view class="wifi-icon" v-if="device.status === 1">
-                <view class="wifi-arc wifi-arc-1"></view>
-                <view class="wifi-arc wifi-arc-2"></view>
-                <view class="wifi-arc wifi-arc-3"></view>
-                <view class="wifi-dot"></view>
+                <view class="wi wi-1"></view>
+                <view class="wi wi-2"></view>
+                <view class="wi wi-3"></view>
+                <view class="wi-dot"></view>
               </view>
-              <view class="wifi-icon offline" v-else>
-                <view class="wifi-arc wifi-arc-1"></view>
-                <view class="wifi-arc wifi-arc-2"></view>
-                <view class="wifi-arc wifi-arc-3"></view>
-                <view class="wifi-dot"></view>
-                <view class="wifi-slash"></view>
+              <view class="wifi-icon off" v-else>
+                <view class="wi wi-1"></view>
+                <view class="wi wi-2"></view>
+                <view class="wi wi-3"></view>
+                <view class="wi-dot"></view>
+                <view class="wi-cut"></view>
               </view>
-              <text class="status-text">{{ getStatusText(device.status) }}</text>
+              <text class="status-label">{{ statusText(device.status) }}</text>
             </view>
-            <view class="device-info">
-              <text class="device-name">{{ device.name }}</text>
-              <view class="device-meta">
-                <text class="meta-label">设备编号</text>
-                <text class="meta-value">{{ device.deviceId }}</text>
-                <text class="meta-divider" v-if="device.shopName">|</text>
+
+            <view class="card-info">
+              <text class="info-name">{{ device.name }}</text>
+              <view class="info-meta">
+                <text class="meta-id">{{ device.deviceId }}</text>
+                <text class="meta-sep" v-if="device.shopName">·</text>
                 <text class="meta-shop" v-if="device.shopName">{{ device.shopName }}</text>
               </view>
             </view>
           </view>
 
-          <!-- 功能入口 -->
-          <view class="card-actions">
-            <view class="action-item" @click.stop="goProductConfig(device.deviceId)">
-              <view class="action-icon orange">
-                <text class="action-num">2</text>
+          <!-- 操作入口 -->
+          <view class="card-foot">
+            <view class="foot-item" @click.stop="goProductConfig(device.deviceId)">
+              <view class="foot-icon">
+                <view class="fi-box">
+                  <view class="fi-box-top"></view>
+                  <view class="fi-box-body"></view>
+                </view>
               </view>
-              <text class="action-label">配置上架商品</text>
+              <text class="foot-label">上架商品</text>
             </view>
-            <view class="action-item" @click.stop="goRestockerConfig(device.deviceId)">
-              <view class="action-icon orange">
-                <text class="action-num">3</text>
+            <view class="foot-item" @click.stop="goRestockerConfig(device.deviceId)">
+              <view class="foot-icon">
+                <view class="fi-person">
+                  <view class="fi-head"></view>
+                  <view class="fi-torso"></view>
+                </view>
               </view>
-              <text class="action-label">配置补货员</text>
+              <text class="foot-label">配置补货员</text>
             </view>
-            <view class="arrow-icon">
-              <view class="arrow-right"></view>
+            <view class="foot-arrow">
+              <view class="fa-inner"></view>
             </view>
           </view>
         </view>
       </view>
 
-      <!-- 加载状态 -->
-      <view class="loading-more" v-if="loading">
-        <view class="loading-spinner"></view>
-        <text>加载中...</text>
+      <!-- 加载更多 -->
+      <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="no-more" v-if="!hasMore && deviceList.length > 0">
-        <text>— 没有更多了 —</text>
+      <!-- 没有更多 -->
+      <view class="end-tip" v-if="!hasMore && deviceList.length > 0">
+        <view class="end-line"></view>
+        <text class="end-text">没有更多了</text>
+        <view class="end-line"></view>
       </view>
 
       <!-- 空状态 -->
-      <view class="empty-state" v-if="!loading && deviceList.length === 0">
+      <view class="empty" v-if="!loading && deviceList.length === 0">
         <view class="empty-icon">
           <view class="empty-wifi">
-            <view class="wifi-arc-big"></view>
-            <view class="wifi-dot-big"></view>
+            <view class="ew-arc"></view>
+            <view class="ew-dot"></view>
           </view>
         </view>
-        <text class="empty-text">暂无设备数据</text>
-        <text class="empty-subtext">请检查网络或添加设备</text>
+        <text class="empty-title">暂无设备数据</text>
+        <text class="empty-desc">下拉刷新或检查网络后重试</text>
       </view>
     </scroll-view>
 
-    <!-- 自定义TabBar -->
     <CustomTabBar />
   </view>
 </template>
@@ -182,28 +190,24 @@ const keyword = ref('');
 const activeTab = ref<TabType>('all');
 const refreshing = ref(false);
 const totalCount = ref(0);
-const isScrolled = ref(false);
 
 const statusBarHeight = ref(44);
 
-// 根据tab获取状态筛选值
 const statusFilter = computed(() => {
   if (activeTab.value === 'online') return 1;
   if (activeTab.value === 'offline') return 0;
   return undefined;
 });
 
-const getStatusText = (status: number) => DeviceStatusText[status] || '未知';
+const statusText = (status: number) => DeviceStatusText[status] || '未知';
 
-const getStatusClass = (status: number) => {
-  if (status === 1) return 'online';
-  if (status === 0) return 'offline';
-  if (status === 2) return 'maintenance';
-  return 'offline';
+const statusClass = (status: number) => {
+  if (status === 1) return 'on';
+  if (status === 0) return 'off';
+  if (status === 2) return 'mt';
+  return 'off';
 };
 
-
-
 const loadDevices = async () => {
   if (loading.value) return;
   loading.value = true;
@@ -228,7 +232,7 @@ const loadDevices = async () => {
     totalCount.value = res.total || 0;
     hasMore.value = deviceList.value.length < (res.total || 0);
   } catch (error) {
-    logger.warn('加载设备列表失败', error);
+    // ignore
   } finally {
     loading.value = false;
     refreshing.value = false;
@@ -283,7 +287,6 @@ const showFilter = () => {
       const tab = map[res.tapIndex];
       if (tab === 'maintenance' || tab === 'error') {
         activeTab.value = 'all';
-        // 可以通过额外的筛选参数处理
         page.value = 1;
         loadDevices();
       } else {
@@ -305,11 +308,6 @@ const goRestockerConfig = (deviceId: string) => {
   uni.showToast({ title: '配置补货员功能开发中', icon: 'none' });
 };
 
-const onScroll = (e: any) => {
-  const scrollTop = e.detail?.scrollTop || 0;
-  isScrolled.value = scrollTop > 10;
-};
-
 onMounted(() => {
   const systemInfo = uni.getSystemInfoSync();
   statusBarHeight.value = systemInfo.statusBarHeight || 44;
@@ -318,6 +316,7 @@ onMounted(() => {
 </script>
 
 <style lang="scss" scoped>
+// ====== Page ======
 .page {
   height: 100vh;
   background: $bg-color-page;
@@ -328,16 +327,11 @@ onMounted(() => {
   padding-bottom: env(safe-area-inset-bottom);
 }
 
-/* ========== 黄色头部区域 ========== */
-.header-section {
+// ====== Header ======
+.header {
   background: $primary-color;
   padding-bottom: 20rpx;
-  transition: box-shadow 0.2s ease;
   flex-shrink: 0;
-
-  &.has-shadow {
-    box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12);
-  }
 }
 
 .status-bar {
@@ -349,7 +343,6 @@ onMounted(() => {
   align-items: center;
   justify-content: center;
   height: 88rpx;
-  position: relative;
 
   .title-text {
     font-size: 36rpx;
@@ -358,26 +351,27 @@ onMounted(() => {
   }
 }
 
-/* Tab 切换 */
+// ====== Tab bar ======
 .tab-bar {
   display: flex;
   align-items: center;
   justify-content: space-around;
   margin-top: 8rpx;
-  padding: 0 40rpx;
+  padding: 0 48rpx;
 }
 
 .tab-item {
   display: flex;
   flex-direction: column;
   align-items: center;
-  padding: 16rpx 24rpx;
+  padding: 16rpx 20rpx;
   position: relative;
 
   .tab-text {
-    font-size: 30rpx;
+    font-size: 28rpx;
     color: $text-color-secondary;
     font-weight: 500;
+    transition: color 0.15s ease;
   }
 
   &.active .tab-text {
@@ -385,24 +379,23 @@ onMounted(() => {
     font-weight: 600;
   }
 
-  .tab-indicator {
+  .tab-line {
     position: absolute;
     bottom: 0;
-    width: 48rpx;
-    height: 6rpx;
+    width: 40rpx;
+    height: 5rpx;
     background: $text-color-primary;
     border-radius: 3rpx;
   }
 }
 
-/* ========== 搜索区域 ========== */
-.search-section {
+// ====== Search bar ======
+.search-bar {
   display: flex;
   align-items: center;
   gap: 16rpx;
   padding: 20rpx 24rpx;
   background: $bg-color-card;
-  border-bottom: 1rpx solid $bg-color-secondary;
   flex-shrink: 0;
 }
 
@@ -412,34 +405,33 @@ onMounted(() => {
   align-items: center;
   background: $bg-color-page;
   border-radius: 40rpx;
-  padding: 16rpx 24rpx;
-  height: 72rpx;
+  padding: 14rpx 24rpx;
+  height: 68rpx;
 }
 
 .search-icon {
   position: relative;
-  width: 32rpx;
-  height: 32rpx;
-  margin-right: 16rpx;
+  width: 30rpx;
+  height: 30rpx;
+  margin-right: 14rpx;
   flex-shrink: 0;
 
-  .search-icon-circle {
-    width: 24rpx;
-    height: 24rpx;
+  .si-ring {
+    width: 22rpx;
+    height: 22rpx;
     border: 3rpx solid $text-color-muted;
     border-radius: 50%;
     position: absolute;
     top: 0;
     left: 0;
   }
-
-  .search-icon-line {
-    width: 10rpx;
+  .si-stem {
+    width: 9rpx;
     height: 3rpx;
     background: $text-color-muted;
     border-radius: 2rpx;
     position: absolute;
-    bottom: 2rpx;
+    bottom: 3rpx;
     right: 2rpx;
     transform: rotate(45deg);
   }
@@ -449,26 +441,21 @@ onMounted(() => {
   flex: 1;
   font-size: 28rpx;
   color: $text-color-primary;
-  height: 100%;
 }
 
-.search-placeholder {
+.search-ph {
   color: $text-color-muted;
-  font-size: 28rpx;
+  font-size: 26rpx;
 }
 
 .search-btn {
   background: $primary-color;
   border-radius: 36rpx;
-  padding: 16rpx 36rpx;
-  display: flex;
-  align-items: center;
-  justify-content: center;
+  padding: 14rpx 32rpx;
   flex-shrink: 0;
+  transition: opacity 0.15s ease;
 
-  &:active {
-    opacity: 0.85;
-  }
+  &:active { opacity: 0.8; }
 
   .search-btn-text {
     font-size: 28rpx;
@@ -477,150 +464,126 @@ onMounted(() => {
   }
 }
 
-/* ========== 统计与操作栏 ========== */
-.stats-bar {
+// ====== Toolbar ======
+.toolbar {
   display: flex;
   align-items: center;
-  justify-content: space-between;
-  padding: 20rpx 24rpx;
+  padding: 18rpx 24rpx;
   background: $bg-color-card;
-  border-bottom: 1rpx solid $bg-color-secondary;
+  border-bottom: 1rpx solid $border-color-light;
   flex-shrink: 0;
+  gap: 20rpx;
 }
 
-.stats-total {
-  font-size: 26rpx;
+.tb-count {
+  font-size: 24rpx;
   color: $text-color-muted;
   flex-shrink: 0;
 }
 
-.stats-refresh {
-  display: flex;
-  align-items: center;
+.tb-refresh {
   flex: 1;
+  display: flex;
   justify-content: center;
-  margin: 0 16rpx;
-  min-width: 0;
+  transition: opacity 0.15s ease;
 
-  .refresh-text {
-    font-size: 24rpx;
-    color: $text-color-muted;
-    white-space: nowrap;
-  }
+  &:active { opacity: 0.6; }
 
-  .refresh-link {
+  .tb-refresh-text {
     font-size: 24rpx;
     color: $info-color;
-    white-space: nowrap;
+    font-weight: 500;
   }
 }
 
-.stats-filter {
+.tb-filter {
   display: flex;
   align-items: center;
   gap: 6rpx;
   flex-shrink: 0;
+  transition: opacity 0.15s ease;
 
-  &:active {
-    opacity: 0.7;
-  }
+  &:active { opacity: 0.6; }
 
-  .filter-text {
-    font-size: 26rpx;
+  .tb-filter-text {
+    font-size: 24rpx;
     color: $text-color-secondary;
+    font-weight: 500;
   }
 
-  .filter-icon {
+  .tb-filter-arrow {
     width: 0;
     height: 0;
-    border-left: 8rpx solid transparent;
-    border-right: 8rpx solid transparent;
-    border-top: 10rpx solid $text-color-secondary;
-    margin-top: 4rpx;
+    border-left: 7rpx solid transparent;
+    border-right: 7rpx solid transparent;
+    border-top: 9rpx solid $text-color-secondary;
   }
 }
 
-/* ========== 设备列表滚动区域 ========== */
-.device-scroll {
+// ====== List ======
+.list-scroll {
   flex: 1;
   height: 0;
 }
 
-.device-list {
+.card-set {
   padding: 20rpx 24rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 18rpx;
 }
 
-/* ========== 设备卡片 ========== */
-.device-card {
+// ====== Card ======
+.card {
   background: $bg-color-card;
-  border-radius: 16rpx;
-  padding: 24rpx;
-  margin-bottom: 20rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
-
-  &:active {
-    transform: scale(0.98);
-    transition: transform 0.15s;
-  }
+  border-radius: 20rpx;
+  padding: 28rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+  transition: opacity 0.15s ease;
+
+  &:active { opacity: 0.7; }
 }
 
-/* 卡片头部 */
-.card-header {
+.card-head {
   display: flex;
   align-items: flex-start;
-  gap: 16rpx;
-  margin-bottom: 24rpx;
+  gap: 18rpx;
 }
 
-.status-badge {
+// Status chip
+.status-chip {
   display: flex;
   align-items: center;
-  gap: 6rpx;
-  padding: 6rpx 14rpx;
-  border-radius: 8rpx;
+  gap: 8rpx;
+  padding: 8rpx 16rpx;
+  border-radius: 10rpx;
   flex-shrink: 0;
-  margin-top: 4rpx;
-
-  &.online {
-    background: $success-color-bg;
-  }
+  margin-top: 2rpx;
 
-  &.offline {
-    background: $bg-color-page;
-  }
-
-  &.maintenance {
-    background: $accent-color-bg;
-  }
+  &.on { background: $success-color-bg; }
+  &.off { background: $bg-color-page; }
+  &.mt { background: $accent-color-bg; }
 
-  .status-text {
+  .status-label {
     font-size: 22rpx;
     font-weight: 500;
   }
 
-  &.online .status-text {
-    color: $success-color;
-  }
-
-  &.offline .status-text {
-    color: $text-color-muted;
-  }
-
-  &.maintenance .status-text {
-    color: $warning-color;
-  }
+  &.on .status-label { color: $success-color; }
+  &.off .status-label { color: $text-color-muted; }
+  &.mt .status-label { color: $warning-color; }
 }
 
-/* WiFi 图标 */
+// WiFi icon
 .wifi-icon {
   position: relative;
-  width: 28rpx;
-  height: 22rpx;
+  width: 26rpx;
+  height: 20rpx;
   display: flex;
   align-items: flex-end;
   justify-content: center;
 
-  .wifi-arc {
+  .wi {
     position: absolute;
     border-radius: 50%;
     border-style: solid;
@@ -628,67 +591,36 @@ onMounted(() => {
     border-left-color: transparent;
     border-right-color: transparent;
   }
-
-  .wifi-arc-1 {
-    width: 24rpx;
-    height: 12rpx;
-    border-width: 3rpx;
-    bottom: 4rpx;
-  }
-
-  .wifi-arc-2 {
-    width: 18rpx;
-    height: 9rpx;
-    border-width: 3rpx;
-    bottom: 4rpx;
-  }
-
-  .wifi-arc-3 {
-    width: 12rpx;
-    height: 6rpx;
-    border-width: 3rpx;
-    bottom: 4rpx;
-  }
-
-  .wifi-dot {
-    width: 5rpx;
-    height: 5rpx;
-    border-radius: 50%;
-    position: absolute;
-    bottom: 0;
-  }
-}
-
-.wifi-icon:not(.offline) {
-  .wifi-arc {
-    border-color: $success-color;
-  }
-  .wifi-dot {
-    background: $success-color;
-  }
-}
-
-.wifi-icon.offline {
-  .wifi-arc {
-    border-color: $text-color-placeholder;
-  }
-  .wifi-dot {
-    background: $text-color-placeholder;
-  }
-
-  .wifi-slash {
-    position: absolute;
-    width: 2rpx;
-    height: 24rpx;
-    background: $error-color;
-    transform: rotate(45deg);
-    top: -2rpx;
-    left: 50%;
-    margin-left: -1rpx;
+  .wi-1 { width: 22rpx; height: 11rpx; border-width: 3rpx; bottom: 3rpx; }
+  .wi-2 { width: 16rpx; height: 8rpx; border-width: 3rpx; bottom: 3rpx; }
+  .wi-3 { width: 10rpx; height: 5rpx; border-width: 3rpx; bottom: 3rpx; }
+  .wi-dot {
+    width: 5rpx; height: 5rpx; border-radius: 50%; position: absolute; bottom: 0;
+  }
+
+  &:not(.off) {
+    .wi { border-color: $success-color; }
+    .wi-dot { background: $success-color; }
+  }
+  &.off {
+    .wi { border-color: $text-color-placeholder; }
+    .wi-dot { background: $text-color-placeholder; }
+    .wi-cut {
+      position: absolute;
+      width: 2rpx;
+      height: 24rpx;
+      background: $error-color;
+      transform: rotate(45deg);
+      top: -2rpx;
+      left: 50%;
+      margin-left: -1rpx;
+      border-radius: 1rpx;
+    }
   }
 }
 
-.device-info {
+// Card info
+.card-info {
   flex: 1;
   min-width: 0;
   display: flex;
@@ -696,196 +628,268 @@ onMounted(() => {
   gap: 8rpx;
 }
 
-.device-name {
+.info-name {
   font-size: 30rpx;
   color: $text-color-primary;
-  font-weight: 500;
+  font-weight: 600;
   line-height: 1.4;
-  word-break: break-all;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
 }
 
-.device-meta {
+.info-meta {
   display: flex;
   align-items: center;
-  flex-wrap: wrap;
   gap: 8rpx;
+  flex-wrap: wrap;
+}
 
-  .meta-label {
-    font-size: 22rpx;
-    color: $text-color-muted;
-    background: $bg-color-page;
-    padding: 2rpx 8rpx;
-    border-radius: 4rpx;
-  }
-
-  .meta-value {
-    font-size: 24rpx;
-    color: $text-color-secondary;
-    font-weight: 500;
-    font-family: monospace;
-  }
+.meta-id {
+  font-size: 24rpx;
+  color: $text-color-secondary;
+  font-weight: 500;
+  font-family: monospace;
+}
 
-  .meta-divider {
-    font-size: 22rpx;
-    color: $border-color;
-  }
+.meta-sep {
+  font-size: 22rpx;
+  color: $border-color;
+}
 
-  .meta-shop {
-    font-size: 24rpx;
-    color: $text-color-muted;
-    white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
-    max-width: 300rpx;
-  }
+.meta-shop {
+  font-size: 24rpx;
+  color: $text-color-muted;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 240rpx;
 }
 
-/* 功能入口 */
-.card-actions {
+// Card footer (actions)
+.card-foot {
   display: flex;
   align-items: center;
-  padding-top: 16rpx;
-  border-top: 1rpx solid $bg-color-page;
+  padding-top: 20rpx;
+  margin-top: 20rpx;
+  border-top: 1rpx solid $border-color-light;
+  gap: 36rpx;
 }
 
-.action-item {
+.foot-item {
   display: flex;
   flex-direction: column;
   align-items: center;
-  margin-right: 48rpx;
+  transition: opacity 0.15s ease;
 
-  &:active {
-    opacity: 0.7;
-  }
+  &:active { opacity: 0.6; }
 }
 
-.action-icon {
-  width: 64rpx;
-  height: 64rpx;
+.foot-icon {
+  width: 56rpx;
+  height: 56rpx;
   border-radius: 50%;
+  background: $accent-color-bg;
   display: flex;
   align-items: center;
   justify-content: center;
-  margin-bottom: 10rpx;
+  margin-bottom: 8rpx;
+}
 
-  &.orange {
-    background: $accent-color-bg;
-    border: 2rpx solid $accent-color-bg;
-  }
+.foot-label {
+  font-size: 22rpx;
+  color: $text-color-secondary;
+  font-weight: 500;
+}
 
-  .action-num {
-    font-size: 28rpx;
-    font-weight: 600;
-    color: $accent-color;
+// Product icon (box)
+.fi-box {
+  position: relative;
+  width: 28rpx;
+  height: 28rpx;
+
+  .fi-box-top {
+    position: absolute;
+    top: 0;
+    left: 50%;
+    transform: translateX(-50%);
+    width: 20rpx;
+    height: 6rpx;
+    background: $accent-color;
+    border-radius: 3rpx 3rpx 0 0;
+  }
+  .fi-box-body {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    width: 28rpx;
+    height: 18rpx;
+    background: $accent-color;
+    border-radius: 4rpx;
+    &::after {
+      content: '';
+      position: absolute;
+      top: 6rpx;
+      left: 50%;
+      transform: translateX(-50%);
+      width: 14rpx;
+      height: 2rpx;
+      background: $accent-color-bg;
+      border-radius: 1rpx;
+    }
   }
 }
 
-.action-label {
-  font-size: 24rpx;
-  color: $text-color-secondary;
+// Person icon
+.fi-person {
+  position: relative;
+  width: 24rpx;
+  height: 28rpx;
+
+  .fi-head {
+    position: absolute;
+    top: 0;
+    left: 50%;
+    transform: translateX(-50%);
+    width: 12rpx;
+    height: 12rpx;
+    background: $accent-color;
+    border-radius: 50%;
+  }
+  .fi-torso {
+    position: absolute;
+    bottom: 0;
+    left: 50%;
+    transform: translateX(-50%);
+    width: 22rpx;
+    height: 12rpx;
+    background: $accent-color;
+    border-radius: 11rpx 11rpx 2rpx 2rpx;
+  }
 }
 
-.arrow-icon {
+// Arrow
+.foot-arrow {
   margin-left: auto;
-  padding: 16rpx;
+  width: 36rpx;
+  height: 36rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
 
-  .arrow-right {
-    width: 16rpx;
-    height: 16rpx;
-    border-top: 3rpx solid $text-color-placeholder;
-    border-right: 3rpx solid $text-color-placeholder;
+  .fa-inner {
+    width: 14rpx;
+    height: 14rpx;
+    border-top: 2rpx solid $text-color-placeholder;
+    border-right: 2rpx solid $text-color-placeholder;
     transform: rotate(45deg);
   }
 }
 
-/* ========== 加载状态 ========== */
-.loading-more {
+// ====== Load more ======
+.load-tip {
   display: flex;
-  align-items: center;
   justify-content: center;
-  gap: 12rpx;
-  padding: 32rpx;
-  color: $text-color-muted;
-  font-size: 24rpx;
+  padding: 36rpx 0 20rpx;
+}
 
-  .loading-spinner {
-    width: 32rpx;
-    height: 32rpx;
-    border: 3rpx solid $border-color;
-    border-top-color: $primary-color;
-    border-radius: 50%;
-    animation: spin 1s linear infinite;
-  }
+.dot-row {
+  display: flex;
+  gap: 8rpx;
 }
 
-@keyframes spin {
-  to {
-    transform: rotate(360deg);
-  }
+.pulse-dot {
+  width: 9rpx;
+  height: 9rpx;
+  background: $text-color-placeholder;
+  border-radius: 50%;
+  animation: pulse 1.2s ease-in-out infinite;
+
+  &:nth-child(2) { animation-delay: 0.2s; }
+  &:nth-child(3) { animation-delay: 0.4s; }
 }
 
-.no-more {
-  text-align: center;
-  padding: 32rpx;
+@keyframes pulse {
+  0%, 80%, 100% { transform: scale(0.5); opacity: 0.4; }
+  40% { transform: scale(1); opacity: 1; }
+}
+
+// ====== End ======
+.end-tip {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 16rpx;
+  padding: 36rpx 0 20rpx;
+}
+
+.end-line {
+  width: 44rpx;
+  height: 1rpx;
+  background: $border-color;
+}
+
+.end-text {
   font-size: 24rpx;
-  color: $text-color-placeholder;
+  color: $text-color-muted;
 }
 
-/* ========== 空状态 ========== */
-.empty-state {
+// ====== Empty ======
+.empty {
   display: flex;
   flex-direction: column;
   align-items: center;
-  padding: 120rpx 0;
+  padding: 100rpx 0 60rpx;
+  gap: 20rpx;
+}
 
-  .empty-icon {
-    width: 140rpx;
-    height: 140rpx;
-    background: $bg-color-page;
-    border-radius: 50%;
-    margin-bottom: 24rpx;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
+.empty-icon {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 50%;
+  background: $bg-color-secondary;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
 
-  .empty-wifi {
-    position: relative;
-    width: 60rpx;
-    height: 50rpx;
-    display: flex;
-    align-items: flex-end;
-    justify-content: center;
-  }
+.empty-wifi {
+  position: relative;
+  width: 54rpx;
+  height: 44rpx;
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+}
 
-  .wifi-arc-big {
-    position: absolute;
-    width: 56rpx;
-    height: 28rpx;
-    border-radius: 50%;
-    border: 4rpx solid $text-color-placeholder;
-    border-bottom-color: transparent;
-    border-left-color: transparent;
-    border-right-color: transparent;
-    bottom: 10rpx;
-  }
+.ew-arc {
+  position: absolute;
+  width: 50rpx;
+  height: 25rpx;
+  border-radius: 50%;
+  border: 4rpx solid $text-color-placeholder;
+  border-bottom-color: transparent;
+  border-left-color: transparent;
+  border-right-color: transparent;
+  bottom: 8rpx;
+}
 
-  .wifi-dot-big {
-    width: 10rpx;
-    height: 10rpx;
-    border-radius: 50%;
-    background: $text-color-placeholder;
-  }
+.ew-dot {
+  width: 10rpx;
+  height: 10rpx;
+  border-radius: 50%;
+  background: $text-color-placeholder;
+}
 
-  .empty-text {
-    font-size: 30rpx;
-    color: $text-color-secondary;
-    margin-bottom: 12rpx;
-  }
+.empty-title {
+  font-size: 28rpx;
+  color: $text-color-secondary;
+  font-weight: 500;
+}
 
-  .empty-subtext {
-    font-size: 26rpx;
-    color: $text-color-muted;
-  }
+.empty-desc {
+  font-size: 24rpx;
+  color: $text-color-muted;
 }
 </style>

+ 31 - 26
haha-admin-mp/src/pages/index/index.vue

@@ -106,11 +106,11 @@
             </view>
             <text class="quick-name">新品申请</text>
           </view>
-          <view class="quick-item" @click="navigateTo('/pages/marketing/list')">
+          <view class="quick-item" @click="navigateTo('/pages/replenisher/list')">
             <view class="quick-icon">
-              <view class="icon-marketing"></view>
+              <view class="icon-replenisher"></view>
             </view>
-            <text class="quick-name">营销管理</text>
+            <text class="quick-name">补货员管理</text>
           </view>
           <view class="quick-item" @click="navigateTo('/pages/staff/list')">
             <view class="quick-icon">
@@ -138,6 +138,7 @@ import { ref, computed, onMounted } from 'vue';
 import { logger } from '@/utils/logger';
 import { getDashboardOverview, getTodoList } from '@/api/dashboard';
 import { formatMoney as formatMoneyUtil } from '@/utils/common';
+import { get } from '@/utils/request';
 import CustomTabBar from '@/components/CustomTabBar.vue';
 
 const statusBarHeight = ref(20); // 状态栏高度,默认20px
@@ -148,11 +149,8 @@ const todo = ref<any>({
   lowStockItems: 0
 });
 
-// 系统通知列表
-const noticeList = ref<any[]>([
-  { id: 1, content: '服务费账单功能升级通知', type: 'system' },
-  { id: 2, content: '应政府监管要求,请尽快完成资质备案信息填写', type: 'important' }
-]);
+// 系统通知列表(从后端获取已发布的公告)
+const noticeList = ref<any[]>([]);
 
 const todoItems = computed(() => [
   { key: 'pendingRefunds', label: '待处理退款', count: todo.value.pendingRefunds || 0, url: '/pages/orders/list?tab=refund' },
@@ -170,14 +168,15 @@ const navigateTo = (url: string) => {
 };
 
 const handleNoticeClick = () => {
-  uni.showToast({ title: '通知中心开发中', icon: 'none' });
+  uni.navigateTo({ url: '/pages/announcement/list' });
 };
 
 const loadData = async () => {
   try {
-    const [overviewRes, todoRes] = await Promise.all([
+    const [overviewRes, todoRes, announcements] = await Promise.all([
       getDashboardOverview(),
-      getTodoList()
+      getTodoList(),
+      get('/announcement/list', { status: 1, page: 1, pageSize: 5 }).then((data: any) => data?.list || [])
     ]);
     overview.value = overviewRes || {};
     if (todoRes) {
@@ -187,6 +186,10 @@ const loadData = async () => {
         lowStockItems: todoRes.lowStockItems || 0
       };
     }
+    noticeList.value = (announcements || []).map((item: any) => ({
+      ...item,
+      content: item.title
+    }));
   } catch (error) {
     logger.warn('获取仪表盘数据失败', error);
   }
@@ -617,33 +620,35 @@ onMounted(() => {
   }
 }
 
-.icon-marketing {
+.icon-replenisher {
   width: 36rpx;
   height: 36rpx;
   position: relative;
 
+  // Box body
   &::before {
     content: '';
     position: absolute;
-    top: 50%;
-    left: 0;
-    transform: translateY(-50%);
-    width: 26rpx;
-    height: 16rpx;
+    bottom: 0;
+    left: 50%;
+    transform: translateX(-50%);
+    width: 32rpx;
+    height: 24rpx;
     background: $primary-color;
-    border-radius: 2rpx 0 0 2rpx;
+    border-radius: 5rpx;
   }
+  // Handle on top
   &::after {
     content: '';
     position: absolute;
-    top: 50%;
-    right: 0;
-    transform: translateY(-50%);
-    width: 0;
-    height: 0;
-    border-top: 14rpx solid transparent;
-    border-bottom: 14rpx solid transparent;
-    border-left: 14rpx solid $primary-color;
+    top: 0;
+    left: 50%;
+    transform: translateX(-50%);
+    width: 16rpx;
+    height: 9rpx;
+    border: 3rpx solid $primary-color;
+    border-radius: 5rpx 5rpx 0 0;
+    border-bottom: none;
   }
 }
 

+ 0 - 3
haha-admin-mp/src/pages/login/login.vue

@@ -10,13 +10,11 @@
           </view>
         </view>
         <text class="brand-name">哈哈运营平台</text>
-        <text class="brand-subtitle">欢迎回来,请登录您的账户</text>
       </view>
       
       <!-- 登录表单 -->
       <view class="login-card">
         <view class="form-group">
-          <text class="form-label">用户名</text>
           <view class="input-wrapper">
             <view class="input-icon user"></view>
             <input 
@@ -30,7 +28,6 @@
         </view>
         
         <view class="form-group">
-          <text class="form-label">密码</text>
           <view class="input-wrapper">
             <view class="input-icon lock"></view>
             <input 

+ 393 - 0
haha-admin-mp/src/pages/products/apply-detail.vue

@@ -0,0 +1,393 @@
+<template>
+  <view class="page">
+    <NavBar title="申请详情" :showBack="true" />
+
+    <scroll-view class="detail-scroll" scroll-y v-if="detail">
+      <view class="status-banner" :class="getStatusClass(detail.status)">
+        <view class="status-dot"></view>
+        <text class="status-text">{{ getStatusText(detail.status) }}</text>
+      </view>
+
+      <view class="detail-section">
+        <view class="section-title">
+          <text class="section-title-text">基本信息</text>
+        </view>
+
+        <view class="detail-row">
+          <text class="detail-label">申请编号</text>
+          <text class="detail-value">#{{ detail.id }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">商品名称</text>
+          <text class="detail-value">{{ detail.productName || '-' }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">条形码</text>
+          <text class="detail-value">{{ detail.barcode || '-' }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">分类</text>
+          <text class="detail-value">{{ detail.category || '-' }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">品牌</text>
+          <text class="detail-value">{{ detail.brand || '-' }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">规格</text>
+          <text class="detail-value">{{ detail.specification || '-' }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">单位</text>
+          <text class="detail-value">{{ detail.unit || '-' }}</text>
+        </view>
+      </view>
+
+      <view class="detail-section">
+        <view class="section-title">
+          <text class="section-title-text">价格信息</text>
+        </view>
+
+        <view class="detail-row">
+          <text class="detail-label">售价</text>
+          <text class="detail-value price">¥{{ formatMoney(detail.price || 0) }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">成本价</text>
+          <text class="detail-value">¥{{ formatMoney(detail.costPrice || 0) }}</text>
+        </view>
+      </view>
+
+      <view class="detail-section" v-if="imageUrls.length > 0">
+        <view class="section-title">
+          <text class="section-title-text">商品图片</text>
+        </view>
+        <view class="image-gallery">
+          <image
+            class="gallery-image"
+            v-for="(img, index) in imageUrls"
+            :key="index"
+            :src="img"
+            mode="aspectFill"
+            @click="previewImage(index)"
+          />
+        </view>
+      </view>
+
+      <view class="detail-section" v-if="detail.description">
+        <view class="section-title">
+          <text class="section-title-text">商品描述</text>
+        </view>
+        <text class="detail-desc">{{ detail.description }}</text>
+      </view>
+
+      <view class="detail-section">
+        <view class="section-title">
+          <text class="section-title-text">申请信息</text>
+        </view>
+
+        <view class="detail-row">
+          <text class="detail-label">申请人</text>
+          <text class="detail-value">{{ detail.applicantName || '-' }}</text>
+        </view>
+        <view class="detail-row">
+          <text class="detail-label">申请时间</text>
+          <text class="detail-value">{{ detail.applyTime ? formatDateTime(detail.applyTime) : '-' }}</text>
+        </view>
+        <view class="detail-row" v-if="detail.auditTime">
+          <text class="detail-label">审核时间</text>
+          <text class="detail-value">{{ formatDateTime(detail.auditTime) }}</text>
+        </view>
+        <view class="detail-row" v-if="detail.auditorName">
+          <text class="detail-label">审核人</text>
+          <text class="detail-value">{{ detail.auditorName }}</text>
+        </view>
+        <view class="detail-row" v-if="detail.auditRemark">
+          <text class="detail-label">审核备注</text>
+          <text class="detail-value">{{ detail.auditRemark }}</text>
+        </view>
+      </view>
+
+      <view class="bottom-spacer"></view>
+    </scroll-view>
+
+    <view class="footer-bar" v-if="detail && detail.status === 0">
+      <view class="btn-delete" @click="handleDelete">
+        <text class="btn-delete-text">删除</text>
+      </view>
+      <view class="btn-edit" @click="goEdit">
+        <text class="btn-edit-text">编辑</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import {
+  getNewProductApplyById,
+  deleteNewProductApply,
+  type NewProductApplyItem
+} from '@/api/newProductApply';
+import { formatMoney, formatDateTime, showConfirm, showToast } from '@/utils/common';
+import { IMAGE_BASE_URL } from '@/utils/config';
+
+const detail = ref<NewProductApplyItem | null>(null);
+const applyId = ref(0);
+
+const imageUrls = computed(() => {
+  if (!detail.value?.images) return [];
+  return detail.value.images
+    .split(',')
+    .filter((url: string) => url.trim())
+    .map((url: string) => {
+      if (url.startsWith('http')) return url;
+      return `${IMAGE_BASE_URL}${url}`;
+    });
+});
+
+const getStatusText = (status?: number) => {
+  const map: Record<number, string> = {
+    0: '待提交',
+    1: '待审核',
+    2: '已通过',
+    3: '已拒绝'
+  };
+  return map[status ?? -1] || '未知';
+};
+
+const getStatusClass = (status?: number) => {
+  const map: Record<number, string> = {
+    0: 'draft',
+    1: 'pending',
+    2: 'approved',
+    3: 'rejected'
+  };
+  return map[status ?? -1] || 'draft';
+};
+
+const previewImage = (index: number) => {
+  uni.previewImage({
+    current: imageUrls.value[index],
+    urls: imageUrls.value
+  });
+};
+
+const loadDetail = async (id: number) => {
+  try {
+    uni.showLoading({ title: '加载中...', mask: true });
+    const res = await getNewProductApplyById(id);
+    uni.hideLoading();
+    if (res) {
+      detail.value = res;
+    }
+  } catch (error) {
+    uni.hideLoading();
+    console.error('加载详情失败', error);
+  }
+};
+
+const handleDelete = async () => {
+  const confirmed = await showConfirm('确定要删除该申请吗?');
+  if (!confirmed) return;
+  try {
+    await deleteNewProductApply(applyId.value);
+    showToast('删除成功', 'success');
+    setTimeout(() => {
+      uni.navigateBack();
+    }, 500);
+  } catch (error) {
+    console.error('删除失败', error);
+  }
+};
+
+const goEdit = () => {
+  uni.navigateTo({ url: `/pages/products/apply-form?id=${applyId.value}` });
+};
+
+onMounted(() => {
+  const pages = getCurrentPages();
+  const currentPage = pages[pages.length - 1] as any;
+  const options = currentPage?.options || {};
+  const id = options.id ? Number(options.id) : 0;
+  if (id) {
+    applyId.value = id;
+    loadDetail(id);
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.page {
+  min-height: 100vh;
+  background: $bg-color-page;
+  display: flex;
+  flex-direction: column;
+}
+
+.detail-scroll {
+  flex: 1;
+  padding: 24rpx;
+  height: 0;
+}
+
+.status-banner {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 28rpx;
+  border-radius: $radius-lg;
+  margin-bottom: 20rpx;
+  gap: 10rpx;
+
+  &.draft {
+    background: $bg-color-secondary;
+    .status-dot { background: $text-color-placeholder; }
+  }
+  &.pending {
+    background: $warning-color-bg;
+    .status-dot { background: $warning-color; }
+  }
+  &.approved {
+    background: $success-color-bg;
+    .status-dot { background: $success-color; }
+  }
+  &.rejected {
+    background: $error-color-bg;
+    .status-dot { background: $error-color; }
+  }
+}
+
+.status-dot {
+  width: 16rpx;
+  height: 16rpx;
+  border-radius: 50%;
+}
+
+.status-text {
+  font-size: $font-size-lg;
+  font-weight: 700;
+  color: $text-color-primary;
+}
+
+.detail-section {
+  background: $bg-color-card;
+  border-radius: $radius-lg;
+  padding: 0 24rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+
+.section-title {
+  padding: 28rpx 0 12rpx;
+}
+
+.section-title-text {
+  font-size: $font-size-base;
+  font-weight: 600;
+  color: $text-color-primary;
+}
+
+.detail-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16rpx 0;
+}
+
+.detail-label {
+  font-size: $font-size-sm;
+  color: $text-color-muted;
+  flex-shrink: 0;
+  margin-right: 24rpx;
+}
+
+.detail-value {
+  font-size: $font-size-sm;
+  color: $text-color-primary;
+  text-align: right;
+  word-break: break-all;
+
+  &.price {
+    color: $primary-color-dark;
+    font-weight: 600;
+  }
+}
+
+.image-gallery {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+  padding: 20rpx 0;
+}
+
+.gallery-image {
+  width: 200rpx;
+  height: 200rpx;
+  border-radius: $radius-base;
+  background: $bg-color-page;
+}
+
+.detail-desc {
+  display: block;
+  padding: 20rpx 0;
+  font-size: $font-size-sm;
+  color: $text-color-secondary;
+  line-height: 1.6;
+}
+
+.bottom-spacer {
+  height: 140rpx;
+}
+
+.footer-bar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  gap: 20rpx;
+  padding: 20rpx 24rpx;
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  background: $bg-color-card;
+  border-top: 1rpx solid $border-color;
+  z-index: 100;
+}
+
+.btn-delete {
+  flex: 1;
+  height: 88rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: $radius-full;
+  border: 2rpx solid $error-color;
+
+  &:active { opacity: 0.7; }
+}
+
+.btn-delete-text {
+  font-size: $font-size-lg;
+  color: $error-color;
+  font-weight: 500;
+}
+
+.btn-edit {
+  flex: 2;
+  height: 88rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: $primary-color;
+  border-radius: $radius-full;
+
+  &:active { opacity: 0.8; }
+}
+
+.btn-edit-text {
+  font-size: $font-size-lg;
+  color: $text-color-primary;
+  font-weight: 600;
+}
+</style>

+ 728 - 0
haha-admin-mp/src/pages/products/apply-form.vue

@@ -0,0 +1,728 @@
+<template>
+  <view class="page">
+    <NavBar :title="pageTitle" :showBack="true" />
+
+    <scroll-view class="form-scroll" scroll-y>
+      <view class="form-section">
+        <view class="section-title">
+          <text class="section-title-text">基本信息</text>
+        </view>
+
+        <view class="form-item required">
+          <text class="form-label">商品名称</text>
+          <view class="form-input-wrap">
+            <input
+              class="form-input"
+              v-model="formData.productName"
+              placeholder="请输入商品名称"
+              :maxlength="50"
+              :disabled="isView"
+            />
+          </view>
+        </view>
+
+        <view class="form-item required">
+          <text class="form-label">条形码</text>
+          <view class="form-input-wrap">
+            <input
+              class="form-input"
+              v-model="formData.barcode"
+              placeholder="请输入条形码"
+              :maxlength="30"
+              :disabled="isView"
+              @blur="onBarcodeBlur"
+            />
+            <view v-if="!isView" class="scan-btn" @click="scanBarcode">
+              <view class="scan-icon"></view>
+            </view>
+          </view>
+          <text v-if="barcodeWarning" class="form-warning">该条码已存在商品库中</text>
+        </view>
+
+        <view class="form-item">
+          <text class="form-label">分类</text>
+          <view class="form-input-wrap">
+            <input
+              class="form-input"
+              v-model="formData.category"
+              placeholder="请输入分类"
+              :maxlength="20"
+              :disabled="isView"
+            />
+          </view>
+        </view>
+
+        <view class="form-item">
+          <text class="form-label">品牌</text>
+          <view class="form-input-wrap">
+            <input
+              class="form-input"
+              v-model="formData.brand"
+              placeholder="请输入品牌"
+              :maxlength="30"
+              :disabled="isView"
+            />
+          </view>
+        </view>
+
+        <view class="form-item">
+          <text class="form-label">规格</text>
+          <view class="form-input-wrap">
+            <input
+              class="form-input"
+              v-model="formData.specification"
+              placeholder="请输入规格"
+              :maxlength="30"
+              :disabled="isView"
+            />
+          </view>
+        </view>
+
+        <view class="form-item">
+          <text class="form-label">单位</text>
+          <view class="form-input-wrap">
+            <input
+              class="form-input"
+              v-model="formData.unit"
+              placeholder="请输入单位"
+              :maxlength="10"
+              :disabled="isView"
+            />
+          </view>
+        </view>
+      </view>
+
+      <view class="form-section">
+        <view class="section-title">
+          <text class="section-title-text">价格信息</text>
+        </view>
+
+        <view class="form-item">
+          <text class="form-label">售价(元)</text>
+          <view class="form-input-wrap">
+            <input
+              class="form-input"
+              type="digit"
+              :value="formData.price"
+              placeholder="请输入售价"
+              :disabled="isView"
+              @input="onPriceInput('price', $event)"
+              @blur="onPriceBlur('price')"
+            />
+          </view>
+        </view>
+
+        <view class="form-item">
+          <text class="form-label">成本价(元)</text>
+          <view class="form-input-wrap">
+            <input
+              class="form-input"
+              type="digit"
+              :value="formData.costPrice"
+              placeholder="请输入成本价"
+              :disabled="isView"
+              @input="onPriceInput('costPrice', $event)"
+              @blur="onPriceBlur('costPrice')"
+            />
+          </view>
+        </view>
+      </view>
+
+      <view class="form-section">
+        <view class="section-title">
+          <text class="section-title-text">商品图片</text>
+        </view>
+
+        <view class="image-upload-area">
+          <view class="image-list">
+            <view
+              class="image-item"
+              v-for="(img, index) in imageList"
+              :key="index"
+            >
+              <image
+                class="upload-image"
+                :src="img"
+                mode="aspectFill"
+                @click="previewImage(index)"
+              />
+              <view v-if="!isView" class="image-delete" @click="removeImage(index)">
+                <text class="delete-icon">×</text>
+              </view>
+            </view>
+            <view
+              v-if="!isView && imageList.length < maxImageCount"
+              class="image-add"
+              @click="chooseImage"
+            >
+              <text class="add-icon">+</text>
+              <text class="add-text">上传图片</text>
+            </view>
+          </view>
+          <text class="image-tip" v-if="!isView">最多上传{{ maxImageCount }}张图片</text>
+        </view>
+      </view>
+
+      <view class="form-section">
+        <view class="section-title">
+          <text class="section-title-text">商品描述</text>
+        </view>
+
+        <view class="form-item textarea-item">
+          <textarea
+            class="form-textarea"
+            v-model="formData.description"
+            placeholder="请输入商品描述"
+            :maxlength="500"
+            :disabled="isView"
+            auto-height
+            :style="{ minHeight: '160rpx' }"
+          />
+          <text v-if="!isView" class="char-count">{{ (formData.description || '').length }}/500</text>
+        </view>
+      </view>
+
+      <view class="bottom-spacer"></view>
+    </scroll-view>
+
+    <view class="footer-bar" v-if="!isView">
+      <view class="btn-draft" @click="handleSaveDraft" v-if="!isEdit">
+        <text class="btn-draft-text">存草稿</text>
+      </view>
+      <view class="btn-submit" @click="handleSubmit">
+        <text class="btn-submit-text">{{ isEdit ? '保存修改' : '提交申请' }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import {
+  getNewProductApplyById,
+  submitNewProductApply,
+  saveNewProductApplyDraft,
+  updateNewProductApply,
+  checkBarcode,
+  type NewProductApplyItem
+} from '@/api/newProductApply';
+import { showToast } from '@/utils/common';
+import { UPLOAD_URL, IMAGE_BASE_URL } from '@/utils/config';
+
+type PageMode = 'add' | 'edit' | 'view';
+
+const mode = ref<PageMode>('add');
+const editId = ref<number>(0);
+const barcodeWarning = ref(false);
+const maxImageCount = 9;
+const imageList = ref<string[]>([]);
+
+const isEdit = computed(() => mode.value === 'edit');
+const isView = computed(() => mode.value === 'view');
+const pageTitle = computed(() => {
+  if (isView.value) return '查看申请';
+  if (isEdit.value) return '编辑申请';
+  return '新品申请';
+});
+
+const formData = reactive<NewProductApplyItem>({
+  productName: '',
+  barcode: '',
+  category: '',
+  brand: '',
+  specification: '',
+  unit: '',
+  price: 0,
+  costPrice: 0,
+  images: '',
+  description: ''
+});
+
+const validate = (): string | null => {
+  if (!formData.productName.trim()) {
+    return '请输入商品名称';
+  }
+  if (!formData.barcode.trim()) {
+    return '请输入条形码';
+  }
+  return null;
+};
+
+const onPriceInput = (field: 'price' | 'costPrice', e: any) => {
+  let val = e.detail.value;
+  val = val.replace(/[^\d.]/g, '');
+  const dotIndex = val.indexOf('.');
+  if (dotIndex !== -1) {
+    val = val.slice(0, dotIndex + 3);
+  }
+  formData[field] = val === '' ? 0 : parseFloat(val);
+};
+
+const onPriceBlur = (field: 'price' | 'costPrice') => {
+  let val = formData[field];
+  if (val === null || val === undefined || isNaN(val as any)) {
+    formData[field] = 0;
+  } else {
+    formData[field] = Math.max(0, parseFloat(parseFloat(String(val)).toFixed(2)));
+  }
+};
+
+const scanBarcode = () => {
+  uni.scanCode({
+    scanType: ['barCode'],
+    success: (res) => {
+      if (res.result) {
+        formData.barcode = res.result;
+        onBarcodeBlur();
+      }
+    },
+    fail: () => {
+      showToast('扫码取消或失败');
+    }
+  });
+};
+
+const onBarcodeBlur = async () => {
+  if (!formData.barcode.trim()) {
+    barcodeWarning.value = false;
+    return;
+  }
+  try {
+    const res = await checkBarcode(formData.barcode.trim());
+    if (res && res.exists) {
+      barcodeWarning.value = true;
+      if (res.product) {
+        formData.productName = res.product.productName || formData.productName;
+        formData.category = res.product.category || formData.category;
+        formData.brand = res.product.brand || formData.brand;
+        formData.specification = res.product.specification || formData.specification;
+        formData.unit = res.product.unit || formData.unit;
+        formData.price = res.product.price || formData.price;
+      }
+    } else {
+      barcodeWarning.value = false;
+    }
+  } catch (error) {
+    barcodeWarning.value = false;
+  }
+};
+
+const chooseImage = () => {
+  const remaining = maxImageCount - imageList.value.length;
+  if (remaining <= 0) return;
+  uni.chooseImage({
+    count: remaining,
+    sizeType: ['compressed'],
+    sourceType: ['album', 'camera'],
+    success: async (res) => {
+      for (const tempPath of res.tempFilePaths) {
+        try {
+          const uploadedUrl = await uploadImage(tempPath);
+          imageList.value.push(uploadedUrl);
+        } catch (error) {
+          console.error('上传图片失败', error);
+          imageList.value.push(tempPath);
+        }
+      }
+      formData.images = imageList.value.join(',');
+    },
+    fail: () => {
+      showToast('选择图片取消');
+    }
+  });
+};
+
+const uploadImage = (tempFilePath: string): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    uni.uploadFile({
+      url: UPLOAD_URL,
+      filePath: tempFilePath,
+      name: 'file',
+      success: (uploadRes) => {
+        try {
+          const data = JSON.parse(uploadRes.data);
+          if (data.code === 200 && data.data) {
+            const url = data.data.url || data.data;
+            resolve(url.startsWith('http') ? url : `${IMAGE_BASE_URL}${url}`);
+          } else {
+            reject(new Error(data.message || '上传失败'));
+          }
+        } catch {
+          reject(new Error('解析上传结果失败'));
+        }
+      },
+      fail: (err) => {
+        reject(err);
+      }
+    });
+  });
+};
+
+const removeImage = (index: number) => {
+  imageList.value.splice(index, 1);
+  formData.images = imageList.value.join(',');
+};
+
+const previewImage = (index: number) => {
+  const urls = imageList.value.map(img => {
+    if (img.startsWith('http')) return img;
+    return `${IMAGE_BASE_URL}${img}`;
+  });
+  uni.previewImage({
+    current: urls[index],
+    urls
+  });
+};
+
+const handleSaveDraft = async () => {
+  const error = validate();
+  if (error) {
+    showToast(error);
+    return;
+  }
+  try {
+    uni.showLoading({ title: '保存中...', mask: true });
+    await saveNewProductApplyDraft(formData);
+    uni.hideLoading();
+    showToast('草稿保存成功', 'success');
+    setTimeout(() => {
+      uni.navigateBack();
+    }, 500);
+  } catch (error) {
+    uni.hideLoading();
+    console.error('保存草稿失败', error);
+  }
+};
+
+const handleSubmit = async () => {
+  const error = validate();
+  if (error) {
+    showToast(error);
+    return;
+  }
+  try {
+    uni.showLoading({ title: '提交中...', mask: true });
+    if (isEdit.value) {
+      await updateNewProductApply(editId.value, formData);
+      showToast('修改成功', 'success');
+    } else {
+      await submitNewProductApply(formData);
+      showToast('提交成功', 'success');
+    }
+    uni.hideLoading();
+    setTimeout(() => {
+      uni.navigateBack();
+    }, 500);
+  } catch (error) {
+    uni.hideLoading();
+    console.error('提交失败', error);
+  }
+};
+
+const loadDetail = async (id: number) => {
+  try {
+    uni.showLoading({ title: '加载中...', mask: true });
+    const res = await getNewProductApplyById(id);
+    uni.hideLoading();
+    if (res) {
+      Object.assign(formData, {
+        productName: res.productName || '',
+        barcode: res.barcode || '',
+        category: res.category || '',
+        brand: res.brand || '',
+        specification: res.specification || '',
+        unit: res.unit || '',
+        price: res.price || 0,
+        costPrice: res.costPrice || 0,
+        images: res.images || '',
+        description: res.description || ''
+      });
+      if (res.images) {
+        imageList.value = res.images.split(',').filter((url: string) => url.trim());
+      } else {
+        imageList.value = [];
+      }
+    }
+  } catch (error) {
+    uni.hideLoading();
+    console.error('加载详情失败', error);
+  }
+};
+
+onMounted(() => {
+  const pages = getCurrentPages();
+  const currentPage = pages[pages.length - 1] as any;
+  const options = currentPage?.options || {};
+  const id = options.id ? Number(options.id) : 0;
+  const pageMode = options.mode || '';
+
+  if (id) {
+    editId.value = id;
+    if (pageMode === 'view') {
+      mode.value = 'view';
+    } else {
+      mode.value = 'edit';
+    }
+    loadDetail(id);
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.page {
+  min-height: 100vh;
+  background: $bg-color-page;
+  display: flex;
+  flex-direction: column;
+}
+
+.form-scroll {
+  flex: 1;
+  padding: 24rpx;
+  height: 0;
+}
+
+.form-section {
+  background: $bg-color-card;
+  border-radius: $radius-lg;
+  padding: 0 24rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+
+.section-title {
+  padding: 28rpx 0 12rpx;
+}
+
+.section-title-text {
+  font-size: $font-size-base;
+  font-weight: 600;
+  color: $text-color-primary;
+}
+
+.form-item {
+  padding: 18rpx 0;
+
+  &.required .form-label::before {
+    content: '*';
+    color: $error-color;
+    margin-right: 6rpx;
+  }
+
+  &.textarea-item {
+    padding-bottom: 20rpx;
+  }
+}
+
+.form-label {
+  font-size: $font-size-sm;
+  color: $text-color-secondary;
+  margin-bottom: 12rpx;
+  display: block;
+}
+
+.form-input-wrap {
+  display: flex;
+  align-items: center;
+  background: #FAFAFA;
+  border-radius: $radius-base;
+  padding: 0 20rpx;
+  height: 80rpx;
+}
+
+.form-input {
+  flex: 1;
+  height: 80rpx;
+  font-size: $font-size-base;
+  color: $text-color-primary;
+}
+
+.scan-btn {
+  width: 60rpx;
+  height: 60rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 12rpx;
+  border-radius: $radius-base;
+  background: $primary-color-bg;
+
+  &:active { opacity: 0.7; }
+}
+
+.scan-icon {
+  width: 36rpx;
+  height: 30rpx;
+  border: 3rpx solid $primary-color;
+  border-radius: 4rpx;
+  position: relative;
+
+  &::after {
+    content: '';
+    position: absolute;
+    top: -8rpx;
+    right: -8rpx;
+    width: 12rpx;
+    height: 3rpx;
+    background: $primary-color;
+    border-radius: 2rpx;
+  }
+}
+
+.form-warning {
+  font-size: $font-size-xs;
+  color: $warning-color;
+  margin-top: 8rpx;
+  display: block;
+}
+
+.form-textarea {
+  width: 100%;
+  background: #FAFAFA;
+  border-radius: $radius-base;
+  padding: 20rpx;
+  font-size: $font-size-base;
+  color: $text-color-primary;
+  box-sizing: border-box;
+}
+
+.char-count {
+  display: block;
+  text-align: right;
+  font-size: $font-size-xs;
+  color: $text-color-muted;
+  margin-top: 8rpx;
+}
+
+.image-upload-area {
+  padding: 20rpx 0;
+}
+
+.image-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+
+.image-item {
+  position: relative;
+  width: 200rpx;
+  height: 200rpx;
+}
+
+.upload-image {
+  width: 200rpx;
+  height: 200rpx;
+  border-radius: $radius-base;
+  background: $bg-color-page;
+}
+
+.image-delete {
+  position: absolute;
+  top: -12rpx;
+  right: -12rpx;
+  width: 40rpx;
+  height: 40rpx;
+  background: $error-color;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.2);
+}
+
+.delete-icon {
+  font-size: 28rpx;
+  color: #fff;
+  line-height: 1;
+}
+
+.image-add {
+  width: 200rpx;
+  height: 200rpx;
+  border-radius: $radius-base;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  background: #FAFAFA;
+
+  &:active {
+    opacity: 0.8;
+  }
+}
+
+.add-icon {
+  font-size: 56rpx;
+  color: $text-color-muted;
+  line-height: 1;
+}
+
+.add-text {
+  font-size: $font-size-xs;
+  color: $text-color-muted;
+  margin-top: 8rpx;
+}
+
+.image-tip {
+  font-size: $font-size-xs;
+  color: $text-color-placeholder;
+  margin-top: 12rpx;
+  display: block;
+}
+
+.bottom-spacer {
+  height: 140rpx;
+}
+
+.footer-bar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  gap: 20rpx;
+  padding: 20rpx 24rpx;
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  background: $bg-color-card;
+  border-top: 1rpx solid $border-color;
+  z-index: 100;
+}
+
+.btn-draft {
+  flex: 1;
+  height: 88rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: $radius-full;
+  border: 2rpx solid $border-color;
+
+  &:active { opacity: 0.7; }
+}
+
+.btn-draft-text {
+  font-size: $font-size-lg;
+  color: $text-color-secondary;
+  font-weight: 500;
+}
+
+.btn-submit {
+  flex: 2;
+  height: 88rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: $primary-color;
+  border-radius: $radius-full;
+
+  &:active { opacity: 0.8; }
+}
+
+.btn-submit-text {
+  font-size: $font-size-lg;
+  color: $text-color-primary;
+  font-weight: 600;
+}
+</style>

+ 814 - 0
haha-admin-mp/src/pages/products/apply.vue

@@ -0,0 +1,814 @@
+<template>
+  <view class="page">
+    <NavBar title="新品申请" :showBack="true" />
+
+    <view class="content">
+      <view class="search-bar">
+        <view class="search-input-wrap">
+          <view class="search-icon"></view>
+          <input
+            class="search-input"
+            v-model="searchKeyword"
+            placeholder="搜索商品名称或条形码"
+            confirm-type="search"
+            @confirm="onSearch"
+          />
+          <view v-if="searchKeyword" class="search-clear" @click="clearSearch">
+            <view class="clear-icon"></view>
+          </view>
+        </view>
+        <view class="filter-btn" @click="showFilter = !showFilter">
+          <text :class="['filter-text', { active: form.status !== '' || form.applicantId }]">筛选</text>
+        </view>
+        <view class="reset-btn" @click="resetForm">
+          <text class="reset-text">重置</text>
+        </view>
+      </view>
+
+      <view class="filter-panel" v-if="showFilter">
+        <view class="filter-section">
+          <text class="filter-section-label">申请状态</text>
+          <view class="filter-tags">
+            <view
+              class="filter-tag"
+              :class="{ active: form.status === '' }"
+              @click="selectStatus('')"
+            >
+              <text>全部</text>
+            </view>
+            <view
+              class="filter-tag"
+              :class="{ active: form.status === 0 }"
+              @click="selectStatus(0)"
+            >
+              <text>待提交</text>
+            </view>
+            <view
+              class="filter-tag"
+              :class="{ active: form.status === 1 }"
+              @click="selectStatus(1)"
+            >
+              <text>待审核</text>
+            </view>
+            <view
+              class="filter-tag"
+              :class="{ active: form.status === 2 }"
+              @click="selectStatus(2)"
+            >
+              <text>已通过</text>
+            </view>
+            <view
+              class="filter-tag"
+              :class="{ active: form.status === 3 }"
+              @click="selectStatus(3)"
+            >
+              <text>已拒绝</text>
+            </view>
+          </view>
+        </view>
+        <view class="filter-section">
+          <text class="filter-section-label">申请人</text>
+          <view class="filter-input-wrap">
+            <input
+              class="filter-input"
+              v-model="applicantNameInput"
+              placeholder="请输入申请人"
+              confirm-type="search"
+              @confirm="onSearch"
+            />
+          </view>
+        </view>
+      </view>
+
+      <scroll-view
+        class="list-scroll"
+        scroll-y
+        @scrolltolower="loadMore"
+        refresher-enabled
+        :refresher-triggered="refreshing"
+        @refresherrefresh="onRefresh"
+      >
+        <view v-if="dataList.length === 0 && !loading" class="empty-state">
+          <view class="empty-icon"></view>
+          <text class="empty-title">暂无申请记录</text>
+          <text class="empty-desc">提交新品上架申请</text>
+          <view class="empty-btn" @click="goApplyForm()">
+            <text class="empty-btn-text">立即申请</text>
+          </view>
+        </view>
+
+        <view
+          class="apply-card"
+          v-for="item in dataList"
+          :key="item.id"
+        >
+          <view class="apply-header" @click="goDetail(item)">
+            <view class="apply-id-wrap">
+              <text class="apply-id">#{{ item.id }}</text>
+              <text class="apply-name">{{ item.productName }}</text>
+            </view>
+            <view :class="['status-tag', getStatusClass(item.status)]">
+              <text class="status-text">{{ getStatusText(item.status) }}</text>
+            </view>
+          </view>
+          <view class="apply-info" @click="goDetail(item)">
+            <view class="info-row">
+              <text class="info-label">条形码</text>
+              <text class="info-value">{{ item.barcode || '-' }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">分类</text>
+              <text class="info-value">{{ item.category || '-' }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">品牌</text>
+              <text class="info-value">{{ item.brand || '-' }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">售价</text>
+              <text class="info-value price">¥{{ formatMoney(item.price || 0) }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">申请人</text>
+              <text class="info-value">{{ item.applicantName || '-' }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">申请时间</text>
+              <text class="info-value">{{ item.applyTime ? formatDateTime(item.applyTime) : '-' }}</text>
+            </view>
+          </view>
+          <view class="apply-footer">
+            <view class="footer-left">
+              <view class="action-btn view" @click="goDetail(item)">
+                <text>查看</text>
+              </view>
+              <view
+                v-if="item.status === 0"
+                class="action-btn edit"
+                @click="goApplyForm(item)"
+              >
+                <text>编辑</text>
+              </view>
+              <view
+                v-if="item.status === 0"
+                class="action-btn delete"
+                @click.stop="handleDelete(item)"
+              >
+                <text>删除</text>
+              </view>
+            </view>
+          </view>
+        </view>
+
+        <view class="load-more" v-if="dataList.length > 0">
+          <view class="load-more-row" v-if="loading">
+            <view class="load-spinner"></view>
+            <text class="load-more-text">加载中...</text>
+          </view>
+          <text class="load-more-text" v-else-if="noMore">没有更多了</text>
+        </view>
+      </scroll-view>
+    </view>
+
+    <view class="fab-btn" @click="goApplyForm()">
+      <text class="fab-icon">+</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import {
+  getNewProductApplyList,
+  deleteNewProductApply,
+  type NewProductApplyItem
+} from '@/api/newProductApply';
+import { formatMoney, formatDateTime, showConfirm, showToast } from '@/utils/common';
+
+const form = reactive({
+  productName: '',
+  barcode: '',
+  status: '' as number | '',
+  applicantId: '' as number | ''
+});
+
+const searchKeyword = ref('');
+const applicantNameInput = ref('');
+const showFilter = ref(false);
+const dataList = ref<NewProductApplyItem[]>([]);
+const loading = ref(false);
+const refreshing = ref(false);
+const noMore = ref(false);
+const pagination = reactive({
+  page: 1,
+  pageSize: 10,
+  total: 0
+});
+
+const getStatusText = (status?: number) => {
+  const map: Record<number, string> = {
+    0: '待提交',
+    1: '待审核',
+    2: '已通过',
+    3: '已拒绝'
+  };
+  return map[status ?? -1] || '未知';
+};
+
+const getStatusClass = (status?: number) => {
+  const map: Record<number, string> = {
+    0: 'draft',
+    1: 'pending',
+    2: 'approved',
+    3: 'rejected'
+  };
+  return map[status ?? -1] || 'draft';
+};
+
+const selectStatus = (status: number | '') => {
+  form.status = status;
+  pagination.page = 1;
+  noMore.value = false;
+  loadData();
+};
+
+const onSearch = () => {
+  const keyword = searchKeyword.value.trim();
+  form.productName = keyword;
+  form.barcode = keyword;
+  if (applicantNameInput.value.trim()) {
+    form.applicantId = Number(applicantNameInput.value.trim()) || '';
+  } else {
+    form.applicantId = '';
+  }
+  pagination.page = 1;
+  noMore.value = false;
+  loadData();
+};
+
+const clearSearch = () => {
+  searchKeyword.value = '';
+  form.productName = '';
+  form.barcode = '';
+  pagination.page = 1;
+  noMore.value = false;
+  loadData();
+};
+
+const resetForm = () => {
+  searchKeyword.value = '';
+  applicantNameInput.value = '';
+  form.productName = '';
+  form.barcode = '';
+  form.status = '';
+  form.applicantId = '';
+  showFilter.value = false;
+  pagination.page = 1;
+  noMore.value = false;
+  loadData();
+};
+
+const loadData = async () => {
+  loading.value = true;
+  try {
+    const params: any = {
+      page: pagination.page,
+      pageSize: pagination.pageSize
+    };
+    if (form.productName) params.productName = form.productName;
+    if (form.barcode) params.barcode = form.barcode;
+    if (form.status !== '') params.status = form.status;
+    if (form.applicantId) params.applicantId = form.applicantId;
+
+    const res = await getNewProductApplyList(params);
+    if (res) {
+      const list = res.list || [];
+      if (pagination.page === 1) {
+        dataList.value = list;
+      } else {
+        dataList.value = [...dataList.value, ...list];
+      }
+      pagination.total = res.total || 0;
+      noMore.value = dataList.value.length >= pagination.total;
+    }
+  } catch (error) {
+    console.error('加载新品申请列表失败', error);
+  } finally {
+    loading.value = false;
+    refreshing.value = false;
+  }
+};
+
+const onRefresh = async () => {
+  refreshing.value = true;
+  pagination.page = 1;
+  noMore.value = false;
+  await loadData();
+};
+
+const loadMore = () => {
+  if (loading.value || noMore.value) return;
+  pagination.page++;
+  loadData();
+};
+
+const handleDelete = async (item: NewProductApplyItem) => {
+  const confirmed = await showConfirm('是否确认删除?');
+  if (!confirmed) return;
+  try {
+    await deleteNewProductApply(item.id!);
+    showToast('删除成功', 'success');
+    pagination.page = 1;
+    noMore.value = false;
+    await loadData();
+  } catch (error) {
+    console.error('删除失败', error);
+  }
+};
+
+const goApplyForm = (item?: NewProductApplyItem) => {
+  const url = item?.id
+    ? `/pages/products/apply-form?id=${item.id}`
+    : '/pages/products/apply-form';
+  uni.navigateTo({ url });
+};
+
+const goDetail = (item: NewProductApplyItem) => {
+  uni.navigateTo({ url: `/pages/products/apply-detail?id=${item.id}` });
+};
+
+onMounted(() => {
+  loadData();
+});
+</script>
+
+<style lang="scss" scoped>
+.page {
+  min-height: 100vh;
+  background: $bg-color-page;
+  display: flex;
+  flex-direction: column;
+}
+
+.content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.search-bar {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  padding: 20rpx 24rpx;
+}
+
+.search-input-wrap {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  background: $bg-color-card;
+  border-radius: $radius-full;
+  padding: 0 24rpx;
+  height: 72rpx;
+}
+
+.search-icon {
+  width: 32rpx;
+  height: 32rpx;
+  margin-right: 12rpx;
+  border: 3rpx solid $text-color-muted;
+  border-radius: 50%;
+  position: relative;
+
+  &::after {
+    content: '';
+    position: absolute;
+    bottom: -4rpx;
+    right: -4rpx;
+    width: 8rpx;
+    height: 3rpx;
+    background: $text-color-muted;
+    border-radius: 3rpx;
+    transform: rotate(45deg);
+    transform-origin: left center;
+  }
+}
+
+.search-input {
+  flex: 1;
+  height: 72rpx;
+  font-size: $font-size-sm;
+  color: $text-color-primary;
+}
+
+.search-clear {
+  width: 40rpx;
+  height: 40rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  background: $bg-color-secondary;
+
+  &:active { opacity: 0.7; }
+}
+
+.clear-icon {
+  width: 20rpx;
+  height: 20rpx;
+  position: relative;
+
+  &::before,
+  &::after {
+    content: '';
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    width: 16rpx;
+    height: 2rpx;
+    background: $text-color-muted;
+    border-radius: 1rpx;
+  }
+
+  &::before { transform: translate(-50%, -50%) rotate(45deg); }
+  &::after { transform: translate(-50%, -50%) rotate(-45deg); }
+}
+
+.filter-btn {
+  padding: 0 20rpx;
+  height: 72rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: $bg-color-card;
+  border-radius: $radius-full;
+
+  &:active { opacity: 0.8; }
+}
+
+.filter-text {
+  font-size: $font-size-sm;
+  color: $text-color-secondary;
+
+  &.active {
+    color: $primary-color;
+    font-weight: 600;
+  }
+}
+
+.reset-btn {
+  padding: 0 20rpx;
+  height: 72rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: $bg-color-card;
+  border-radius: $radius-full;
+
+  &:active { opacity: 0.8; }
+}
+
+.reset-text {
+  font-size: $font-size-sm;
+  color: $text-color-muted;
+}
+
+.filter-panel {
+  padding: 8rpx 24rpx 20rpx;
+  background: $bg-color-card;
+  margin: 0 24rpx;
+  border-radius: $radius-lg;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+
+.filter-section {
+  padding: 14rpx 0;
+
+  & + .filter-section {
+    padding-top: 18rpx;
+  }
+}
+
+.filter-section-label {
+  font-size: $font-size-sm;
+  color: $text-color-secondary;
+  font-weight: 500;
+  margin-bottom: 12rpx;
+  display: block;
+}
+
+.filter-tags {
+  display: flex;
+  gap: 16rpx;
+  flex-wrap: wrap;
+}
+
+.filter-tag {
+  padding: 10rpx 28rpx;
+  background: #FAFAFA;
+  border-radius: $radius-full;
+
+  text {
+    font-size: $font-size-sm;
+    color: $text-color-secondary;
+  }
+
+  &.active {
+    background: $primary-color-bg;
+
+    text {
+      color: $primary-color-dark;
+      font-weight: 600;
+    }
+  }
+}
+
+.filter-input-wrap {
+  background: #FAFAFA;
+  border-radius: $radius-base;
+  padding: 0 20rpx;
+  height: 72rpx;
+  display: flex;
+  align-items: center;
+}
+
+.filter-input {
+  flex: 1;
+  height: 72rpx;
+  font-size: $font-size-sm;
+  color: $text-color-primary;
+}
+
+.list-scroll {
+  flex: 1;
+  padding: 0 24rpx;
+  height: 0;
+}
+
+.apply-card {
+  background: $bg-color-card;
+  border-radius: $radius-lg;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+
+.apply-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+
+.apply-id-wrap {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  margin-right: 16rpx;
+}
+
+.apply-id {
+  font-size: $font-size-xs;
+  color: $text-color-muted;
+  margin-right: 12rpx;
+  flex-shrink: 0;
+}
+
+.apply-name {
+  font-size: $font-size-lg;
+  font-weight: 600;
+  color: $text-color-primary;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status-tag {
+  padding: 6rpx 16rpx;
+  border-radius: $radius-full;
+  flex-shrink: 0;
+
+  &.draft {
+    background: $bg-color-secondary;
+
+    .status-text { color: $text-color-muted; }
+  }
+  &.pending {
+    background: $warning-color-bg;
+
+    .status-text { color: $warning-color; }
+  }
+  &.approved {
+    background: $success-color-bg;
+
+    .status-text { color: $success-color; }
+  }
+  &.rejected {
+    background: $error-color-bg;
+
+    .status-text { color: $error-color; }
+  }
+}
+
+.status-text {
+  font-size: $font-size-xs;
+  font-weight: 600;
+}
+
+.apply-info {
+  margin-bottom: 12rpx;
+}
+
+.info-row {
+  display: flex;
+  align-items: baseline;
+  padding: 6rpx 0;
+}
+
+.info-label {
+  font-size: 22rpx;
+  color: $text-color-muted;
+  margin-right: 12rpx;
+  flex-shrink: 0;
+  min-width: 80rpx;
+}
+
+.info-value {
+  font-size: 24rpx;
+  color: $text-color-secondary;
+  word-break: break-all;
+
+  &.price {
+    color: $primary-color-dark;
+    font-weight: 600;
+  }
+}
+
+.apply-footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-top: 20rpx;
+}
+
+.footer-left {
+  display: flex;
+  gap: 16rpx;
+}
+
+.action-btn {
+  padding: 8rpx 24rpx;
+  border-radius: $radius-full;
+
+  text {
+    font-size: $font-size-xs;
+    font-weight: 500;
+  }
+
+  &.view {
+    background: $info-color-bg;
+
+    text { color: $info-color; }
+  }
+  &.edit {
+    background: $primary-color-bg;
+
+    text { color: $primary-color-dark; }
+  }
+  &.delete {
+    background: $error-color-bg;
+
+    text { color: $error-color; }
+  }
+
+  &:active {
+    opacity: 0.7;
+  }
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 120rpx 0 80rpx;
+}
+
+.empty-icon {
+  width: 100rpx;
+  height: 100rpx;
+  border-radius: 50%;
+  background: $bg-color-secondary;
+  margin-bottom: 24rpx;
+  position: relative;
+
+  &::before,
+  &::after {
+    content: '';
+    position: absolute;
+    left: 50%;
+    transform: translateX(-50%);
+    background: $text-color-placeholder;
+  }
+
+  &::before {
+    top: 30rpx;
+    width: 32rpx;
+    height: 4rpx;
+    border-radius: 2rpx;
+  }
+
+  &::after {
+    top: 46rpx;
+    width: 24rpx;
+    height: 4rpx;
+    border-radius: 2rpx;
+  }
+}
+
+.empty-title {
+  font-size: $font-size-base;
+  font-weight: 500;
+  color: $text-color-secondary;
+  margin-bottom: 8rpx;
+}
+
+.empty-desc {
+  font-size: $font-size-sm;
+  color: $text-color-muted;
+  margin-bottom: 32rpx;
+}
+
+.empty-btn {
+  padding: 16rpx 48rpx;
+  background: $primary-color;
+  border-radius: $radius-full;
+
+  &:active { opacity: 0.8; }
+}
+
+.empty-btn-text {
+  font-size: $font-size-base;
+  color: $text-color-primary;
+  font-weight: 600;
+}
+
+.load-more {
+  padding: 20rpx 0 40rpx;
+  text-align: center;
+}
+
+.load-more-row {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10rpx;
+}
+
+.load-spinner {
+  width: 28rpx;
+  height: 28rpx;
+  border: 3rpx solid $border-color;
+  border-top-color: $text-color-muted;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.load-more-text {
+  font-size: 22rpx;
+  color: $text-color-muted;
+}
+
+.fab-btn {
+  position: fixed;
+  right: 40rpx;
+  bottom: 140rpx;
+  width: 100rpx;
+  height: 100rpx;
+  background: $primary-color;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 8rpx 24rpx rgba(255, 193, 7, 0.4);
+  z-index: 100;
+
+  &:active {
+    transform: scale(0.92);
+  }
+}
+
+.fab-icon {
+  font-size: 52rpx;
+  color: $text-color-primary;
+  font-weight: 700;
+  line-height: 1;
+}
+</style>

+ 512 - 0
haha-admin-mp/src/pages/replenisher/bind-device.vue

@@ -0,0 +1,512 @@
+<template>
+  <view class="page">
+    <NavBar title="设备绑定" :showBack="true" @back="goBack" />
+
+    <!-- 补货员信息 -->
+    <view class="header-card">
+      <view class="hc-avatar">
+        <text>{{ (replenisherName || '?')[0] }}</text>
+      </view>
+      <view class="hc-info">
+        <text class="hc-name">{{ replenisherName }}</text>
+        <text class="hc-hint">选择该补货员负责的设备</text>
+      </view>
+    </view>
+
+    <!-- 绑定统计 -->
+    <view class="bind-stats" v-if="!loading">
+      <text>已选 <text class="bs-num">{{ selectedIds.length }}</text> 台设备</text>
+    </view>
+
+    <scroll-view class="page-scroll" scroll-y>
+      <!-- 门店设备列表 -->
+      <view class="shop-list" v-if="!loading">
+        <view class="shop-group" v-for="shop in shopList" :key="shop.id">
+          <view class="shop-header" @click="shop.expanded = !shop.expanded">
+            <view class="sh-arrow" :class="{ expanded: shop.expanded }">
+              <view class="arr-inner"></view>
+            </view>
+            <text class="sh-name">{{ shop.name }}</text>
+            <text class="sh-count">{{ shop.devices.length }}台设备</text>
+            <view class="sh-check" :class="{ partial: getShopState(shop) === 'partial', all: getShopState(shop) === 'all' }" @click.stop="toggleShopAll(shop)">
+              <text v-if="getShopState(shop) === 'all'">✓</text>
+              <text v-else-if="getShopState(shop) === 'partial'">-</text>
+            </view>
+          </view>
+          <view class="shop-devices" v-if="shop.expanded">
+            <view
+              class="device-item"
+              v-for="dev in shop.devices"
+              :key="dev.deviceId"
+              @click="toggleDevice(dev.deviceId)"
+            >
+              <view class="di-check" :class="{ on: selectedIds.includes(dev.deviceId) }">
+                <text v-if="selectedIds.includes(dev.deviceId)">✓</text>
+              </view>
+              <text class="di-name">{{ dev.name || dev.deviceId }}</text>
+              <text class="di-id">{{ dev.deviceId }}</text>
+            </view>
+            <view class="shop-empty" v-if="shop.devices.length === 0">
+              <text>该门店暂无设备</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="empty-state" v-if="!loading && shopList.length === 0">
+        <text class="empty-text">暂无可用门店</text>
+      </view>
+
+      <view class="bottom-safe"></view>
+    </scroll-view>
+
+    <!-- 底部操作栏 -->
+    <view class="bottom-bar" v-if="!loading">
+      <view class="bb-info">
+        <text>变更:绑定 {{ toBind.length }} 台,解绑 {{ toUnbind.length }} 台</text>
+      </view>
+      <view class="bb-btn" :class="{ disabled: toBind.length === 0 && toUnbind.length === 0 }" @click="handleSave">
+        <text class="bb-btn-text">保存</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import { getBoundDevices, bindDevices, unbindDevice } from '@/api/replenisher';
+import { getEnabledShops, getShopDevices } from '@/api/shop';
+
+const replenisherId = ref<number>(0);
+const replenisherName = ref('');
+const loading = ref(true);
+const saving = ref(false);
+const selectedIds = ref<string[]>([]);
+const origBoundIds = ref<string[]>([]);
+
+interface ShopWithDevices {
+  id: number;
+  name: string;
+  devices: { deviceId: string; name: string }[];
+  expanded: boolean;
+}
+
+const shopList = ref<ShopWithDevices[]>([]);
+
+const toBind = computed(() => selectedIds.value.filter(id => !origBoundIds.value.includes(id)));
+const toUnbind = computed(() => origBoundIds.value.filter(id => !selectedIds.value.includes(id)));
+
+const getShopState = (shop: ShopWithDevices): 'none' | 'partial' | 'all' => {
+  if (shop.devices.length === 0) return 'none';
+  const sel = shop.devices.filter(d => selectedIds.value.includes(d.deviceId)).length;
+  if (sel === shop.devices.length) return 'all';
+  if (sel > 0) return 'partial';
+  return 'none';
+};
+
+const toggleDevice = (deviceId: string) => {
+  const idx = selectedIds.value.indexOf(deviceId);
+  if (idx >= 0) {
+    selectedIds.value.splice(idx, 1);
+  } else {
+    selectedIds.value.push(deviceId);
+  }
+};
+
+const toggleShopAll = (shop: ShopWithDevices) => {
+  const allSelected = shop.devices.every(d => selectedIds.value.includes(d.deviceId));
+  if (allSelected) {
+    selectedIds.value = selectedIds.value.filter(id => !shop.devices.some(d => d.deviceId === id));
+  } else {
+    const toAdd = shop.devices.filter(d => !selectedIds.value.includes(d.deviceId)).map(d => d.deviceId);
+    selectedIds.value = [...selectedIds.value, ...toAdd];
+  }
+};
+
+const handleSave = async () => {
+  if (saving.value) return;
+  if (toBind.value.length === 0 && toUnbind.value.length === 0) {
+    uni.showToast({ title: '未做任何变更', icon: 'none' });
+    return;
+  }
+  saving.value = true;
+  uni.showLoading({ title: '保存中...', mask: true });
+  try {
+    if (toBind.value.length > 0) {
+      await bindDevices(replenisherId.value, toBind.value);
+    }
+    if (toUnbind.value.length > 0) {
+      await Promise.all(toUnbind.value.map(deviceId => unbindDevice(replenisherId.value, deviceId)));
+    }
+    uni.hideLoading();
+    uni.showToast({ title: '保存成功', icon: 'success' });
+    setTimeout(() => {
+      uni.navigateBack();
+    }, 1200);
+  } catch (e) {
+    uni.hideLoading();
+    uni.showToast({ title: '保存失败', icon: 'none' });
+  } finally {
+    saving.value = false;
+  }
+};
+
+const goBack = () => {
+  uni.navigateBack();
+};
+
+onMounted(async () => {
+  const pages = getCurrentPages();
+  const page = pages[pages.length - 1];
+  const options = (page as any).options || {};
+  replenisherId.value = Number(options.id) || 0;
+  replenisherName.value = decodeURIComponent(options.name || '');
+
+  if (!replenisherId.value) {
+    uni.showToast({ title: '参数错误', icon: 'none' });
+    return;
+  }
+
+  try {
+    const [shops, boundDevices] = await Promise.all([
+      getEnabledShops(),
+      getBoundDevices(replenisherId.value)
+    ]);
+    origBoundIds.value = boundDevices || [];
+    selectedIds.value = [...origBoundIds.value];
+
+    // 并行获取每个门店的设备
+    const shopPromises = (shops || []).map(async (shop: any) => {
+      try {
+        const devices = await getShopDevices(shop.id);
+        return {
+          id: shop.id,
+          name: shop.name,
+          devices: (devices || []).map((d: any) => ({
+            deviceId: d.deviceId,
+            name: d.name || d.deviceId
+          })),
+          expanded: false
+        };
+      } catch {
+        return { id: shop.id, name: shop.name, devices: [], expanded: false };
+      }
+    });
+    shopList.value = await Promise.all(shopPromises);
+  } catch (e) {
+    uni.showToast({ title: '加载失败', icon: 'none' });
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+$font-stack: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', system-ui, sans-serif;
+
+.page {
+  height: 100vh;
+  width: 100vw;
+  overflow-x: hidden;
+  background: $bg-color-page;
+  display: flex;
+  flex-direction: column;
+  font-family: $font-stack;
+}
+
+// ====== Header ======
+.header-card {
+  display: flex;
+  align-items: center;
+  background: $bg-color-card;
+  padding: 24rpx 32rpx;
+  gap: 20rpx;
+  flex-shrink: 0;
+}
+
+.hc-avatar {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  background: $primary-color-bg;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+
+  text {
+    font-size: 32rpx;
+    font-weight: 700;
+    color: $primary-color-dark;
+  }
+}
+
+.hc-info {
+  flex: 1;
+  min-width: 0;
+}
+
+.hc-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: $text-color-primary;
+  display: block;
+}
+
+.hc-hint {
+  font-size: 24rpx;
+  color: $text-color-muted;
+  margin-top: 4rpx;
+  display: block;
+}
+
+// ====== Stats ======
+.bind-stats {
+  padding: 16rpx 32rpx;
+  font-size: 24rpx;
+  color: $text-color-muted;
+  flex-shrink: 0;
+
+  .bs-num {
+    font-weight: 600;
+    color: $primary-color-dark;
+  }
+}
+
+// ====== Scroll ======
+.page-scroll {
+  flex: 1;
+  height: 0;
+  padding: 0 24rpx;
+}
+
+// ====== Shop list ======
+.shop-group {
+  background: $bg-color-card;
+  border-radius: 16rpx;
+  margin-bottom: 16rpx;
+  overflow: hidden;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+
+.shop-header {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 20rpx 20rpx 16rpx;
+  gap: 12rpx;
+  &:active { background: $bg-color-page; }
+}
+
+.sh-arrow {
+  width: 32rpx;
+  height: 32rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  transition: transform 0.2s ease;
+
+  .arr-inner {
+    width: 12rpx; height: 12rpx;
+    border-top: 2rpx solid $text-color-placeholder;
+    border-right: 2rpx solid $text-color-placeholder;
+    transform: rotate(45deg);
+  }
+
+  &.expanded {
+    transform: rotate(90deg);
+  }
+}
+
+.sh-name {
+  flex: 1;
+  font-size: 28rpx;
+  font-weight: 600;
+  color: $text-color-primary;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.sh-count {
+  font-size: 22rpx;
+  color: $text-color-muted;
+  flex-shrink: 0;
+}
+
+.sh-check {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 8rpx;
+  border: 2rpx solid $border-color;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  font-size: 24rpx;
+  color: transparent;
+  transition: all 0.15s ease;
+
+  &.partial {
+    background: $primary-color-bg;
+    border-color: $primary-color;
+    color: $primary-color-dark;
+  }
+
+  &.all {
+    background: $primary-color;
+    border-color: $primary-color;
+    color: $text-color-primary;
+  }
+}
+
+// ====== Device items ======
+.shop-devices {
+  border-top: 1rpx solid $border-color-light;
+}
+
+.device-item {
+  display: flex;
+  align-items: center;
+  padding: 18rpx 20rpx 18rpx 48rpx;
+  gap: 14rpx;
+  &:active { background: $bg-color-page; }
+
+  &:not(:last-child) {
+    border-bottom: 1rpx solid $border-color-light;
+  }
+}
+
+.di-check {
+  width: 32rpx;
+  height: 32rpx;
+  border-radius: 8rpx;
+  border: 2rpx solid $border-color;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  font-size: 20rpx;
+  color: transparent;
+  transition: all 0.15s ease;
+
+  &.on {
+    background: $primary-color;
+    border-color: $primary-color;
+    color: $text-color-primary;
+  }
+}
+
+.di-name {
+  flex: 1;
+  font-size: 26rpx;
+  color: $text-color-primary;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.di-id {
+  font-size: 22rpx;
+  color: $text-color-muted;
+  flex-shrink: 0;
+}
+
+.shop-empty {
+  padding: 32rpx 0;
+  text-align: center;
+  font-size: 24rpx;
+  color: $text-color-placeholder;
+}
+
+// ====== Bottom bar ======
+.bottom-bar {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 24rpx;
+  padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  background: $bg-color-card;
+  border-top: 1rpx solid $border-color-light;
+  gap: 20rpx;
+  flex-shrink: 0;
+}
+
+.bb-info {
+  flex: 1;
+  font-size: 22rpx;
+  color: $text-color-muted;
+}
+
+.bb-btn {
+  background: $primary-color;
+  border-radius: 40rpx;
+  padding: 18rpx 48rpx;
+  transition: opacity 0.15s ease;
+
+  &:active { opacity: 0.8; }
+
+  &.disabled {
+    opacity: 0.4;
+    pointer-events: none;
+  }
+}
+
+.bb-btn-text {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: $text-color-primary;
+}
+
+// ====== Load / Empty ======
+.load-tip {
+  display: flex;
+  justify-content: center;
+  padding: 60rpx 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;
+  &:nth-child(2) { animation-delay: 0.2s; }
+  &: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; }
+}
+
+.empty-state {
+  display: flex;
+  justify-content: center;
+  padding: 80rpx 0;
+}
+
+.empty-text {
+  font-size: 28rpx;
+  color: $text-color-muted;
+}
+
+.bottom-safe {
+  height: 40rpx;
+}
+</style>

+ 664 - 0
haha-admin-mp/src/pages/replenisher/form.vue

@@ -0,0 +1,664 @@
+<template>
+  <view class="page">
+    <NavBar :title="isEdit ? '编辑补货员' : '新增补货员'" :showBack="true" @back="goBack" />
+
+    <scroll-view class="form-scroll" scroll-y>
+      <!-- 编辑模式:头像区 -->
+      <view class="avatar-card" v-if="isEdit">
+        <view class="ac-avatar" :class="form.status === 0 ? 'off' : ''">
+          <text>{{ (form.name || '?')[0] }}</text>
+        </view>
+        <view class="ac-info">
+          <text class="ac-name">{{ form.name }}</text>
+          <text class="ac-id" v-if="form.employeeId">工号 {{ form.employeeId }}</text>
+          <view class="ac-status" :class="form.status === 1 ? 'on' : 'off'">
+            <view class="ac-dot" :class="form.status === 1 ? 'on' : 'off'"></view>
+            <text>{{ form.status === 1 ? '正常' : '已禁用' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 基本信息 -->
+      <view class="form-card">
+        <view class="section-title">基本信息</view>
+
+        <view class="field">
+          <text class="field-label">姓名<text class="required">*</text></text>
+          <input
+            class="field-input"
+            v-model="form.name"
+            placeholder="请输入补货员姓名"
+            placeholder-class="field-ph"
+          />
+        </view>
+
+        <view class="field">
+          <text class="field-label">手机号</text>
+          <input
+            class="field-input"
+            v-model="form.phone"
+            placeholder="请输入手机号"
+            placeholder-class="field-ph"
+            type="number"
+            maxlength="11"
+          />
+        </view>
+
+        <view class="field">
+          <text class="field-label">工号</text>
+          <input
+            class="field-input"
+            v-model="form.employeeId"
+            placeholder="请输入工号"
+            placeholder-class="field-ph"
+          />
+        </view>
+      </view>
+
+      <!-- 状态 -->
+      <view class="form-card">
+        <view class="section-title">账号状态</view>
+
+        <view class="field">
+          <view class="field-switch-row">
+            <view
+              class="switch-opt"
+              :class="{ on: form.status === 1 }"
+              @click="form.status = 1"
+            >
+              <view class="so-dot on"></view>
+              <text>正常</text>
+            </view>
+            <view
+              class="switch-opt"
+              :class="{ on: form.status === 0 }"
+              @click="form.status = 0"
+            >
+              <view class="so-dot off"></view>
+              <text>禁用</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 绑定信息(仅编辑模式) -->
+      <view class="form-card" v-if="isEdit">
+        <view class="section-title">设备与绑定</view>
+
+        <view class="field" @click="handleBindDevices">
+          <view class="field-picker-row">
+            <text class="field-picker-label">绑定设备</text>
+            <view class="field-picker-right">
+              <text class="picker-val">{{ boundDeviceCount > 0 ? boundDeviceCount + '台设备' : '未绑定' }}</text>
+              <view class="picker-arrow"></view>
+            </view>
+          </view>
+        </view>
+
+        <view class="field" @click="handleGenBindingCode">
+          <view class="field-picker-row">
+            <text class="field-picker-label">微信绑定码</text>
+            <view class="field-picker-right">
+              <text class="picker-val" :class="{ active: !form.wechatOpenid }">{{ form.wechatOpenid ? '已绑定' : '生成绑定码' }}</text>
+              <view class="picker-arrow"></view>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 操作按钮 -->
+      <view class="btn-area">
+        <view class="btn-save" @click="handleSave">
+          <text class="btn-save-text">{{ isEdit ? '保存修改' : '创建补货员' }}</text>
+        </view>
+        <view class="btn-delete" v-if="isEdit" @click="handleDelete">
+          <text class="btn-delete-text">删除补货员</text>
+        </view>
+      </view>
+
+      <view class="bottom-safe"></view>
+    </scroll-view>
+
+    <!-- 绑定码弹窗 -->
+    <view class="overlay" v-if="showCodePanel" @click="showCodePanel = false">
+      <view class="code-panel" @click.stop>
+        <text class="cp-title">微信绑定码</text>
+        <text class="cp-sub">24小时内有效,分享给补货员扫码绑定</text>
+        <view class="cp-code" @click="copyCode">
+          <text class="cp-code-text">{{ formatCode(bindingCode) }}</text>
+        </view>
+        <text class="cp-hint">点击绑定码可复制</text>
+        <view class="cp-close" @click="showCodePanel = false">
+          <text class="cp-close-text">关闭</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import {
+  createReplenisher,
+  updateReplenisher,
+  getReplenisherById,
+  deleteReplenisher,
+  getBoundDevices,
+  generateBindingCode,
+  type ReplenisherForm
+} from '@/api/replenisher';
+
+const isEdit = ref(false);
+const editId = ref<number | null>(null);
+const saving = ref(false);
+const boundDeviceCount = ref(0);
+
+const form = reactive<ReplenisherForm>({
+  name: '',
+  phone: '',
+  employeeId: '',
+  status: 1
+});
+
+// 绑定码弹窗
+const showCodePanel = ref(false);
+const bindingCode = ref('');
+
+const loadDetail = async (id: number) => {
+  try {
+    const data = await getReplenisherById(id);
+    if (data) {
+      form.id = data.id;
+      form.name = data.name || '';
+      form.phone = data.phone || '';
+      form.employeeId = data.employeeId || '';
+      form.status = data.status ?? 1;
+      form.wechatOpenid = data.wechatOpenid || '';
+      boundDeviceCount.value = data.boundDeviceCount || 0;
+    }
+  } catch (e) {
+    uni.showToast({ title: '加载失败', icon: 'none' });
+  }
+};
+
+const validate = (): string | null => {
+  if (!form.name || !form.name.trim()) return '请输入姓名';
+  if (form.phone && !/^1[3-9]\d{9}$/.test(form.phone.trim())) return '请输入正确的手机号';
+  return null;
+};
+
+const handleSave = async () => {
+  if (saving.value) return;
+  const err = validate();
+  if (err) {
+    uni.showToast({ title: err, icon: 'none' });
+    return;
+  }
+  saving.value = true;
+  uni.showLoading({ title: '保存中...', mask: true });
+
+  try {
+    if (isEdit.value) {
+      await updateReplenisher(editId.value!, {
+        name: form.name.trim(),
+        phone: form.phone?.trim() || undefined,
+        employeeId: form.employeeId?.trim() || undefined,
+        status: form.status
+      });
+    } else {
+      await createReplenisher({
+        name: form.name.trim(),
+        phone: form.phone?.trim() || undefined,
+        employeeId: form.employeeId?.trim() || undefined
+      });
+    }
+    uni.hideLoading();
+    uni.showToast({ title: isEdit.value ? '修改成功' : '新增成功', icon: 'success' });
+    setTimeout(() => {
+      uni.navigateBack();
+    }, 1200);
+  } catch (e) {
+    uni.hideLoading();
+    uni.showToast({ title: '保存失败', icon: 'none' });
+  } finally {
+    saving.value = false;
+  }
+};
+
+const handleDelete = () => {
+  uni.showModal({
+    title: '确认删除',
+    content: `确认删除补货员「${form.name}」?删除后不可恢复。`,
+    success: async (res) => {
+      if (!res.confirm) return;
+      try {
+        await deleteReplenisher(editId.value!);
+        uni.showToast({ title: '已删除', icon: 'success' });
+        setTimeout(() => {
+          uni.navigateBack();
+        }, 1200);
+      } catch (e) {
+        uni.showToast({ title: '删除失败', icon: 'none' });
+      }
+    }
+  });
+};
+
+const handleBindDevices = () => {
+  uni.navigateTo({ url: `/pages/replenisher/bind-device?id=${editId.value}&name=${encodeURIComponent(form.name)}` });
+};
+
+const handleGenBindingCode = async () => {
+  if (!editId.value) return;
+  uni.showLoading({ title: '生成中...', mask: true });
+  try {
+    const data = await generateBindingCode(editId.value);
+    bindingCode.value = data.bindingCode || '';
+    showCodePanel.value = true;
+  } catch (e) {
+    uni.showToast({ title: '生成失败', icon: 'none' });
+  } finally {
+    uni.hideLoading();
+  }
+};
+
+const formatCode = (code: string): string => {
+  if (!code) return '';
+  return code.replace(/(.{4})/g, '$1 ').trim();
+};
+
+const copyCode = () => {
+  if (!bindingCode.value) return;
+  uni.setClipboardData({
+    data: bindingCode.value,
+    success: () => {
+      uni.showToast({ title: '绑定码已复制', icon: 'success' });
+    }
+  });
+};
+
+const goBack = () => {
+  uni.navigateBack();
+};
+
+onMounted(() => {
+  const pages = getCurrentPages();
+  const page = pages[pages.length - 1];
+  const options = (page as any).options || {};
+  const id = options.id ? Number(options.id) : null;
+
+  if (id) {
+    isEdit.value = true;
+    editId.value = id;
+    uni.setNavigationBarTitle({ title: '编辑补货员' });
+    loadDetail(id);
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+$font-stack: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', system-ui, sans-serif;
+
+.page {
+  height: 100vh;
+  width: 100vw;
+  overflow-x: hidden;
+  background: $bg-color-page;
+  display: flex;
+  flex-direction: column;
+  font-family: $font-stack;
+}
+
+.form-scroll {
+  flex: 1;
+  width: 100%;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+
+// ====== Avatar card (edit mode) ======
+.avatar-card {
+  display: flex;
+  align-items: center;
+  background: $bg-color-card;
+  border-radius: 20rpx;
+  padding: 32rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+  gap: 24rpx;
+  overflow: hidden;
+}
+
+.ac-avatar {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 50%;
+  background: $primary-color-bg;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+
+  text {
+    font-size: 36rpx;
+    font-weight: 700;
+    color: $primary-color-dark;
+  }
+
+  &.off {
+    background: $bg-color-page;
+    text { color: $text-color-placeholder; }
+  }
+}
+
+.ac-info {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 8rpx;
+}
+
+.ac-name {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: $text-color-primary;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.ac-id {
+  font-size: 24rpx;
+  color: $text-color-muted;
+}
+
+.ac-status {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  margin-top: 8rpx;
+
+  text { font-size: 24rpx; font-weight: 500; }
+  &.on text { color: $success-color; }
+  &.off text { color: $text-color-muted; }
+}
+
+.ac-dot {
+  width: 12rpx; height: 12rpx; border-radius: 50%;
+  flex-shrink: 0;
+  &.on { background: $success-color; }
+  &.off { background: $text-color-placeholder; }
+}
+
+// ====== Form card ======
+.form-card {
+  background: $bg-color-card;
+  border-radius: 20rpx;
+  padding: 32rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+  overflow: hidden;
+}
+
+.section-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: $text-color-primary;
+  margin-bottom: 16rpx;
+  letter-spacing: 2rpx;
+}
+
+// ====== Field ======
+.field {
+  padding: 24rpx 0;
+
+  &:first-of-type {
+    padding-top: 16rpx;
+  }
+
+  &:not(:last-of-type) {
+    border-bottom: 1rpx solid $border-color-light;
+  }
+
+  &:last-child {
+    padding-bottom: 0;
+  }
+}
+
+.field-label {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: $text-color-primary;
+  margin-bottom: 16rpx;
+  display: block;
+}
+
+.required {
+  color: $error-color;
+  margin-left: 4rpx;
+  font-weight: 400;
+}
+
+.field-input {
+  font-size: 28rpx;
+  color: $text-color-primary;
+  background: $bg-color-page;
+  border-radius: 12rpx;
+  padding: 0 24rpx;
+  height: 80rpx;
+  line-height: 80rpx;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.field-ph {
+  color: $text-color-placeholder;
+  font-size: 24rpx;
+}
+
+// ====== Picker row ======
+.field-picker-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.field-picker-label {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: $text-color-primary;
+}
+
+.field-picker-right {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
+
+.picker-val {
+  font-size: 28rpx;
+  color: $text-color-muted;
+
+  &.active {
+    color: $primary-color-dark;
+  }
+}
+
+.picker-arrow {
+  width: 12rpx; height: 12rpx;
+  border-top: 3rpx solid $text-color-placeholder;
+  border-right: 3rpx solid $text-color-placeholder;
+  transform: rotate(45deg);
+  flex-shrink: 0;
+}
+
+// ====== Status switch ======
+.field-switch-row {
+  display: flex;
+  gap: 16rpx;
+}
+
+.switch-opt {
+  flex: 1;
+  min-width: 0;
+  padding: 24rpx 0;
+  border-radius: 16rpx;
+  background: $bg-color-page;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10rpx;
+  transition: background 0.2s ease;
+
+  text {
+    font-size: 28rpx;
+    font-weight: 500;
+    color: $text-color-muted;
+    transition: color 0.2s ease;
+  }
+
+  &.on {
+    background: $primary-color-bg;
+    box-shadow: inset 0 0 0 2rpx rgba(255, 193, 7, 0.2);
+    text { color: $primary-color-dark; font-weight: 600; }
+  }
+
+  &:active { opacity: 0.7; }
+}
+
+.so-dot {
+  width: 16rpx; height: 16rpx; border-radius: 50%;
+  flex-shrink: 0;
+  &.on { background: $success-color; box-shadow: 0 0 8rpx rgba(16, 185, 129, 0.3); }
+  &.off { background: $text-color-placeholder; }
+}
+
+// ====== Buttons ======
+.btn-area {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+  margin-top: 16rpx;
+  align-items: center;
+}
+
+.btn-save {
+  width: 100%;
+  background: $primary-color;
+  border-radius: 48rpx;
+  padding: 28rpx 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 4rpx 16rpx rgba(255, 193, 7, 0.3);
+  transition: opacity 0.15s ease;
+
+  &:active { opacity: 0.8; }
+
+  .btn-save-text {
+    font-size: 32rpx;
+    font-weight: 600;
+    color: $text-color-primary;
+  }
+}
+
+.btn-delete {
+  width: 100%;
+  padding: 24rpx 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: opacity 0.15s ease;
+
+  &:active { opacity: 0.7; }
+
+  .btn-delete-text {
+    font-size: 28rpx;
+    font-weight: 500;
+    color: $error-color;
+  }
+}
+
+.bottom-safe {
+  height: 64rpx;
+}
+
+// ====== Binding code overlay ======
+.overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.45);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 100;
+  padding: 48rpx;
+}
+
+.code-panel {
+  background: $bg-color-card;
+  border-radius: 24rpx;
+  padding: 48rpx 40rpx 40rpx;
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 16rpx;
+}
+
+.cp-title {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: $text-color-primary;
+}
+
+.cp-sub {
+  font-size: 24rpx;
+  color: $text-color-muted;
+  text-align: center;
+  line-height: 1.5;
+}
+
+.cp-code {
+  background: $bg-color-page;
+  border: 2rpx dashed $primary-color;
+  border-radius: 12rpx;
+  padding: 24rpx 32rpx;
+  margin-top: 8rpx;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.cp-code-text {
+  font-size: 32rpx;
+  font-weight: 700;
+  font-family: 'Courier New', Courier, monospace;
+  letter-spacing: 4rpx;
+  color: $primary-color-dark;
+  text-align: center;
+  word-break: break-all;
+  user-select: all;
+}
+
+.cp-hint {
+  font-size: 22rpx;
+  color: $text-color-placeholder;
+}
+
+.cp-close {
+  margin-top: 16rpx;
+  padding: 16rpx 64rpx;
+  background: $bg-color-page;
+  border-radius: 40rpx;
+
+  &:active { opacity: 0.7; }
+}
+
+.cp-close-text {
+  font-size: 28rpx;
+  color: $text-color-secondary;
+  font-weight: 500;
+}
+</style>

+ 709 - 0
haha-admin-mp/src/pages/replenisher/list.vue

@@ -0,0 +1,709 @@
+<template>
+  <view class="page">
+    <NavBar title="补货员管理" :showBack="true" @back="goBack" />
+
+    <!-- 搜索 -->
+    <view class="search-bar">
+      <view class="search-box">
+        <view class="search-icon">
+          <view class="si-ring"></view>
+          <view class="si-stem"></view>
+        </view>
+        <input
+          class="search-input"
+          v-model="keyword"
+          placeholder="姓名 / 手机号 / 工号"
+          placeholder-class="search-ph"
+          confirm-type="search"
+          @confirm="handleSearch"
+        />
+      </view>
+      <view class="search-btn" @click="handleSearch">
+        <text class="search-btn-text">搜索</text>
+      </view>
+    </view>
+
+    <!-- 统计 -->
+    <view class="stats" v-if="statistics">
+      <view class="stat-item">
+        <text class="stat-num">{{ statistics.total || 0 }}</text>
+        <text class="stat-label">总数</text>
+      </view>
+      <view class="stat-divider"></view>
+      <view class="stat-item">
+        <text class="stat-num active">{{ statistics.active || 0 }}</text>
+        <text class="stat-label">正常</text>
+      </view>
+      <view class="stat-divider"></view>
+      <view class="stat-item">
+        <text class="stat-num disabled">{{ statistics.disabled || 0 }}</text>
+        <text class="stat-label">禁用</text>
+      </view>
+    </view>
+
+    <!-- 列表 -->
+    <scroll-view
+      class="list-scroll"
+      scroll-y
+      @scrolltolower="loadMore"
+      refresher-enabled
+      :refresher-triggered="refreshing"
+      @refresherrefresh="onRefresh"
+    >
+      <view class="card-set" v-if="list.length > 0">
+        <view
+          class="card"
+          v-for="item in list"
+          :key="item.id"
+          @click="goForm(item.id)"
+          @longpress="showActions(item)"
+        >
+          <view class="card-avatar" :class="item.status === 0 ? 'off' : ''">
+            <text>{{ (item.name || '?')[0] }}</text>
+          </view>
+
+          <view class="card-info">
+            <view class="info-top">
+              <text class="info-name">{{ item.name }}</text>
+              <view class="info-status" :class="item.status === 1 ? 'on' : 'off'">
+                <view class="info-dot" :class="item.status === 1 ? 'on' : 'off'"></view>
+                <text>{{ item.status === 1 ? '正常' : '禁用' }}</text>
+              </view>
+            </view>
+            <view class="info-sub">
+              <text class="sub-phone" v-if="item.phone">{{ item.phone }}</text>
+              <text class="sub-sep" v-if="item.phone && item.employeeId">·</text>
+              <text class="sub-id" v-if="item.employeeId">工号 {{ item.employeeId }}</text>
+            </view>
+            <view class="info-meta">
+              <text class="meta-item" v-if="item.boundDeviceCount != null">
+                <text class="meta-val">{{ item.boundDeviceCount }}</text>
+                <text class="meta-label">台设备</text>
+              </text>
+              <text class="meta-item" v-if="item.totalTasks != null">
+                <text class="meta-val">{{ item.totalTasks }}</text>
+                <text class="meta-label">次任务</text>
+              </text>
+            </view>
+          </view>
+
+          <view class="card-arrow">
+            <view class="arr-inner"></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">
+        <view class="end-line"></view>
+        <text class="end-text">没有更多了</text>
+        <view class="end-line"></view>
+      </view>
+
+      <!-- 空状态 -->
+      <view class="empty" v-if="!loading && list.length === 0">
+        <view class="empty-icon">
+          <view class="empty-person">
+            <view class="ep-head"></view>
+            <view class="ep-body"></view>
+          </view>
+        </view>
+        <text class="empty-title">暂无补货员</text>
+        <text class="empty-desc">点击右下角按钮新增补货员</text>
+      </view>
+    </scroll-view>
+
+    <!-- 新增按钮 -->
+    <view class="fab" @click="goForm()">
+      <text class="fab-text">+</text>
+    </view>
+
+    <CustomTabBar />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import CustomTabBar from '@/components/CustomTabBar.vue';
+import {
+  getReplenisherList,
+  updateReplenisherStatus,
+  deleteReplenisher,
+  generateBindingCode,
+  type ReplenisherQuery
+} from '@/api/replenisher';
+
+const list = ref<any[]>([]);
+const loading = ref(false);
+const hasMore = ref(true);
+const page = ref(1);
+const pageSize = 10;
+const keyword = ref('');
+const refreshing = ref(false);
+const statistics = ref<{ total: number; active: number; disabled: number } | null>(null);
+
+const fetchList = async () => {
+  if (loading.value) return;
+  loading.value = true;
+  try {
+    const params: ReplenisherQuery = { page: page.value, pageSize };
+    if (keyword.value) {
+      params.keyword = keyword.value;
+    }
+    const res = await getReplenisherList(params);
+    if (page.value === 1) {
+      list.value = res.list || [];
+    } else {
+      list.value = [...list.value, ...(res.list || [])];
+    }
+    hasMore.value = list.value.length < (res.total || 0);
+    // 从列表数据推算统计(如果后端支持的话可以单独调统计接口)
+    if (page.value === 1 && keyword.value === '') {
+      const all = res.list || [];
+      // 有总数字段就用total,否则从当前页推断
+    }
+  } catch (e) {
+    // ignore
+  } finally {
+    loading.value = false;
+    refreshing.value = false;
+  }
+};
+
+const fetchStats = async () => {
+  try {
+    // 用不带筛选条件的列表查询获取全局统计
+    const res = await getReplenisherList({ page: 1, pageSize: 1 });
+    const total = res.total || 0;
+    // 分别查正常和禁用的数量
+    const [activeRes, disabledRes] = await Promise.all([
+      getReplenisherList({ page: 1, pageSize: 1, status: 1 }),
+      getReplenisherList({ page: 1, pageSize: 1, status: 0 })
+    ]);
+    statistics.value = {
+      total,
+      active: activeRes.total || 0,
+      disabled: disabledRes.total || 0
+    };
+  } catch (e) {
+    // ignore
+  }
+};
+
+const loadMore = () => {
+  if (loading.value || !hasMore.value) return;
+  page.value++;
+  fetchList();
+};
+
+const onRefresh = () => {
+  refreshing.value = true;
+  page.value = 1;
+  hasMore.value = true;
+  fetchList();
+  fetchStats();
+};
+
+const handleSearch = () => {
+  page.value = 1;
+  hasMore.value = true;
+  fetchList();
+};
+
+const goForm = (id?: number) => {
+  const url = id != null ? `/pages/replenisher/form?id=${id}` : '/pages/replenisher/form';
+  uni.navigateTo({ url });
+};
+
+const goBack = () => {
+  uni.navigateBack();
+};
+
+const showActions = (item: any) => {
+  const isActive = item.status === 1;
+  uni.showActionSheet({
+    itemList: ['编辑信息', '绑定设备', '生成绑定码', isActive ? '禁用账号' : '启用账号', '删除补货员'],
+    itemColor: '#1E293B',
+    success: (res) => {
+      switch (res.tapIndex) {
+        case 0:
+          goForm(item.id);
+          break;
+        case 1:
+          handleBindDevices(item);
+          break;
+        case 2:
+          handleGenCode(item);
+          break;
+        case 3:
+          handleToggleStatus(item);
+          break;
+        case 4:
+          handleDelete(item);
+          break;
+      }
+    }
+  });
+};
+
+const handleToggleStatus = (item: any) => {
+  const newStatus = item.status === 1 ? 0 : 1;
+  const actionText = newStatus === 0 ? '禁用' : '启用';
+  uni.showModal({
+    title: '确认操作',
+    content: `确认${actionText}补货员「${item.name}」?`,
+    success: async (res) => {
+      if (!res.confirm) return;
+      try {
+        await updateReplenisherStatus(item.id, newStatus);
+        uni.showToast({ title: `已${actionText}`, icon: 'success' });
+        page.value = 1;
+        fetchList();
+        fetchStats();
+      } catch (e) {
+        uni.showToast({ title: '操作失败', icon: 'none' });
+      }
+    }
+  });
+};
+
+const handleBindDevices = (item: any) => {
+  uni.navigateTo({
+    url: `/pages/replenisher/bind-device?id=${item.id}&name=${encodeURIComponent(item.name)}`
+  });
+};
+
+const handleGenCode = async (item: any) => {
+  uni.showLoading({ title: '生成中...', mask: true });
+  try {
+    const data = await generateBindingCode(item.id);
+    uni.hideLoading();
+    const code = data.bindingCode || '';
+    uni.showModal({
+      title: '微信绑定码',
+      content: `${code}\n\n24小时内有效,点击确认复制`,
+      success: (modalRes) => {
+        if (modalRes.confirm) {
+          uni.setClipboardData({
+            data: code,
+            success: () => {
+              uni.showToast({ title: '绑定码已复制', icon: 'success' });
+            }
+          });
+        }
+      }
+    });
+  } catch (e) {
+    uni.hideLoading();
+    uni.showToast({ title: '生成失败', icon: 'none' });
+  }
+};
+
+const handleDelete = (item: any) => {
+  uni.showModal({
+    title: '确认删除',
+    content: `确认删除补货员「${item.name}」?\n删除后不可恢复。`,
+    success: async (res) => {
+      if (!res.confirm) return;
+      try {
+        await deleteReplenisher(item.id);
+        uni.showToast({ title: '已删除', icon: 'success' });
+        page.value = 1;
+        fetchList();
+        fetchStats();
+      } catch (e) {
+        uni.showToast({ title: '删除失败', icon: 'none' });
+      }
+    }
+  });
+};
+
+onMounted(() => {
+  fetchList();
+  fetchStats();
+});
+</script>
+
+<style lang="scss" scoped>
+// ====== Page ======
+.page {
+  height: 100vh;
+  background: $bg-color-page;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  padding-bottom: constant(safe-area-inset-bottom);
+  padding-bottom: env(safe-area-inset-bottom);
+}
+
+// ====== Search ======
+.search-bar {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  padding: 20rpx 24rpx;
+  background: $bg-color-card;
+  flex-shrink: 0;
+}
+
+.search-box {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  background: $bg-color-page;
+  border-radius: 40rpx;
+  padding: 14rpx 24rpx;
+  height: 68rpx;
+}
+
+.search-icon {
+  position: relative;
+  width: 30rpx;
+  height: 30rpx;
+  margin-right: 14rpx;
+  flex-shrink: 0;
+
+  .si-ring {
+    width: 22rpx; height: 22rpx;
+    border: 3rpx solid $text-color-muted;
+    border-radius: 50%;
+    position: absolute; top: 0; left: 0;
+  }
+  .si-stem {
+    width: 9rpx; height: 3rpx;
+    background: $text-color-muted;
+    border-radius: 2rpx;
+    position: absolute; bottom: 3rpx; right: 2rpx;
+    transform: rotate(45deg);
+  }
+}
+
+.search-input {
+  flex: 1;
+  font-size: 28rpx;
+  color: $text-color-primary;
+}
+
+.search-ph {
+  color: $text-color-muted;
+  font-size: 26rpx;
+}
+
+.search-btn {
+  background: $primary-color;
+  border-radius: 36rpx;
+  padding: 14rpx 32rpx;
+  flex-shrink: 0;
+  transition: opacity 0.15s ease;
+  &:active { opacity: 0.8; }
+
+  .search-btn-text {
+    font-size: 28rpx;
+    color: $text-color-primary;
+    font-weight: 500;
+  }
+}
+
+// ====== Stats ======
+.stats {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 32rpx;
+  background: $bg-color-card;
+  border-bottom: 1rpx solid $border-color-light;
+  flex-shrink: 0;
+}
+
+.stat-item {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4rpx;
+}
+
+.stat-num {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: $text-color-primary;
+  &.active { color: $success-color; }
+  &.disabled { color: $text-color-muted; }
+}
+
+.stat-label {
+  font-size: 22rpx;
+  color: $text-color-muted;
+}
+
+.stat-divider {
+  width: 1rpx;
+  height: 36rpx;
+  background: $border-color-light;
+}
+
+// ====== List ======
+.list-scroll {
+  flex: 1;
+  height: 0;
+}
+
+.card-set {
+  padding: 20rpx 24rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 14rpx;
+}
+
+// ====== Card ======
+.card {
+  display: flex;
+  align-items: center;
+  background: $bg-color-card;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+  transition: opacity 0.15s ease;
+  gap: 18rpx;
+
+  &:active { opacity: 0.7; }
+}
+
+.card-avatar {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  background: $primary-color-bg;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+
+  text {
+    font-size: 32rpx;
+    font-weight: 700;
+    color: $primary-color-dark;
+  }
+
+  &.off {
+    background: $bg-color-page;
+    text { color: $text-color-placeholder; }
+  }
+}
+
+.card-info {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 8rpx;
+}
+
+.info-top {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+}
+
+.info-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: $text-color-primary;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.info-status {
+  display: flex;
+  align-items: center;
+  gap: 6rpx;
+  flex-shrink: 0;
+
+  text { font-size: 22rpx; font-weight: 500; }
+
+  &.on text { color: $success-color; }
+  &.off text { color: $text-color-muted; }
+}
+
+.info-dot {
+  width: 10rpx; height: 10rpx; border-radius: 50%;
+  &.on { background: $success-color; }
+  &.off { background: $text-color-placeholder; }
+}
+
+.info-sub {
+  display: flex;
+  align-items: center;
+  gap: 6rpx;
+}
+
+.sub-phone {
+  font-size: 24rpx;
+  color: $text-color-secondary;
+}
+
+.sub-sep {
+  font-size: 22rpx;
+  color: $border-color;
+}
+
+.sub-id {
+  font-size: 24rpx;
+  color: $text-color-muted;
+}
+
+.info-meta {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+
+.meta-item {
+  display: flex;
+  align-items: center;
+  gap: 4rpx;
+}
+
+.meta-val {
+  font-size: 24rpx;
+  font-weight: 600;
+  color: $accent-color;
+}
+
+.meta-label {
+  font-size: 22rpx;
+  color: $text-color-muted;
+}
+
+.card-arrow {
+  width: 36rpx;
+  height: 36rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+
+  .arr-inner {
+    width: 14rpx; height: 14rpx;
+    border-top: 2rpx solid $text-color-placeholder;
+    border-right: 2rpx solid $text-color-placeholder;
+    transform: rotate(45deg);
+  }
+}
+
+// ====== Load more ======
+.load-tip {
+  display: flex;
+  justify-content: center;
+  padding: 36rpx 0 20rpx;
+}
+
+.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;
+  &:nth-child(2) { animation-delay: 0.2s; }
+  &: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 ======
+.end-tip {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 16rpx;
+  padding: 36rpx 0 20rpx;
+}
+.end-line { width: 44rpx; height: 1rpx; background: $border-color; }
+.end-text { font-size: 24rpx; color: $text-color-muted; }
+
+// ====== Empty ======
+.empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 100rpx 0 60rpx;
+  gap: 20rpx;
+}
+
+.empty-icon {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 50%;
+  background: $bg-color-secondary;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.empty-person {
+  position: relative;
+  width: 40rpx;
+  height: 48rpx;
+  .ep-head {
+    position: absolute; top: 0; left: 50%;
+    transform: translateX(-50%);
+    width: 18rpx; height: 18rpx;
+    background: $text-color-placeholder;
+    border-radius: 50%;
+  }
+  .ep-body {
+    position: absolute; bottom: 0; left: 50%;
+    transform: translateX(-50%);
+    width: 34rpx; height: 20rpx;
+    background: $text-color-placeholder;
+    border-radius: 17rpx 17rpx 4rpx 4rpx;
+  }
+}
+
+.empty-title { font-size: 28rpx; color: $text-color-secondary; font-weight: 500; }
+.empty-desc { font-size: 24rpx; color: $text-color-muted; }
+
+// ====== FAB ======
+.fab {
+  position: fixed;
+  right: 40rpx;
+  bottom: 240rpx;
+  width: 96rpx;
+  height: 96rpx;
+  border-radius: 50%;
+  background: $primary-color;
+  box-shadow: 0 8rpx 24rpx rgba(255, 193, 7, 0.35);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 10;
+  transition: opacity 0.15s ease;
+
+  &:active { opacity: 0.8; }
+
+  .fab-text {
+    font-size: 48rpx;
+    color: $text-color-primary;
+    font-weight: 300;
+    line-height: 1;
+    margin-top: -4rpx;
+  }
+}
+</style>

+ 119 - 124
haha-admin-mp/src/pages/shop/detail.vue

@@ -1,11 +1,9 @@
 <template>
   <view class="page">
-    <!-- 导航栏 -->
     <NavBar title="门店详情" :showBack="true" />
-    
-    <view class="detail-card" v-if="shop">
-      <!-- 门店状态 -->
-      <view class="status-section">
+
+    <view class="detail-content" v-if="shop">
+      <view class="status-card">
         <view class="status-header">
           <text class="shop-name">{{ shop.name }}</text>
           <view class="shop-status" :class="getStatusClass(shop.status)">
@@ -14,34 +12,34 @@
           </view>
         </view>
       </view>
-      
-      <!-- 基本信息 -->
-      <view class="info-section">
+
+      <view class="info-card">
         <text class="section-title">基本信息</text>
-        <view class="info-item">
-          <text class="label">门店地址</text>
-          <text class="value">{{ shop.address }}</text>
-        </view>
-        <view class="info-item">
-          <text class="label">联系电话</text>
-          <text class="value">{{ shop.contactPhone }}</text>
-        </view>
-        <view class="info-item">
-          <text class="label">负责人</text>
-          <text class="value">{{ shop.contactName }}</text>
-        </view>
-        <view class="info-item">
-          <text class="label">营业时间</text>
-          <text class="value">{{ shop.businessHours }}</text>
+        <view class="info-grid">
+          <view class="info-item">
+            <text class="label">门店地址</text>
+            <text class="value">{{ shop.address }}</text>
+          </view>
+          <view class="info-item">
+            <text class="label">联系电话</text>
+            <text class="value">{{ shop.contactPhone }}</text>
+          </view>
+          <view class="info-item">
+            <text class="label">负责人</text>
+            <text class="value">{{ shop.contactName }}</text>
+          </view>
+          <view class="info-item">
+            <text class="label">营业时间</text>
+            <text class="value">{{ shop.businessHours }}</text>
+          </view>
         </view>
       </view>
-      
-      <!-- 销售数据 -->
-      <view class="info-section">
+
+      <view class="info-card">
         <text class="section-title">销售数据</text>
         <view class="sales-grid">
           <view class="sales-item">
-            <text class="sales-value accent">¥{{ formatMoney(shop.todaySales) }}</text>
+            <text class="sales-value">¥{{ formatMoney(shop.todaySales) }}</text>
             <text class="sales-label">今日销售额</text>
           </view>
           <view class="sales-item">
@@ -49,14 +47,13 @@
             <text class="sales-label">今日订单</text>
           </view>
           <view class="sales-item">
-            <text class="sales-value primary">¥{{ formatMoney(shop.monthSales) }}</text>
+            <text class="sales-value">¥{{ formatMoney(shop.monthSales) }}</text>
             <text class="sales-label">本月销售额</text>
           </view>
         </view>
       </view>
-      
-      <!-- 门店设备 -->
-      <view class="info-section" v-if="shop.devices && shop.devices.length > 0">
+
+      <view class="info-card" v-if="shop.devices && shop.devices.length > 0">
         <text class="section-title">门店设备</text>
         <view class="device-list">
           <view class="device-item" v-for="device in shop.devices" :key="device.id">
@@ -132,184 +129,182 @@ onMounted(() => {
   background: $bg-color-page;
 }
 
-.status-section {
+.detail-content {
+  padding: $spacing-2 $spacing-3;
+}
+
+.status-card {
   background: $bg-color-card;
-  padding: 30rpx 32rpx;
-  margin-bottom: 16rpx;
-  
+  border-radius: $radius-lg;
+  padding: 28rpx $spacing-4;
+  margin-bottom: $spacing-2;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+
   .status-header {
     display: flex;
     justify-content: space-between;
     align-items: center;
-    
+
     .shop-name {
-      font-size: 36rpx;
+      font-size: $font-size-xl;
       font-weight: 700;
       color: $text-color-primary;
+      line-height: 1.3;
     }
-    
+
     .shop-status {
       display: flex;
       align-items: center;
       justify-content: center;
       gap: 8rpx;
-      padding: 10rpx 20rpx;
-      font-size: 24rpx;
+      padding: 8rpx 18rpx;
+      font-size: 22rpx;
       font-weight: 500;
-      border-radius: 20rpx;
-      
+      border-radius: $radius-full;
+      flex-shrink: 0;
+
       .status-dot {
-        width: 8rpx;
-        height: 8rpx;
+        width: 10rpx;
+        height: 10rpx;
         border-radius: 50%;
       }
-      
+
       &.active {
         background: $success-color-bg;
-        color: $primary-color;
-        .status-dot { background: $primary-color; }
+        color: $success-color;
+        .status-dot { background: $success-color; }
       }
-      
+
       &.inactive {
-        background: $accent-color-bg;
-        color: $accent-color;
-        .status-dot { background: $accent-color; }
+        background: $bg-color-secondary;
+        color: $text-color-muted;
+        .status-dot { background: $text-color-placeholder; }
       }
     }
   }
 }
 
-.info-section {
+.info-card {
   background: $bg-color-card;
-  margin-bottom: 16rpx;
-  padding: 24rpx 32rpx;
-  
+  border-radius: $radius-lg;
+  padding: 24rpx $spacing-4;
+  margin-bottom: $spacing-2;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+
   .section-title {
     display: block;
-    font-size: 28rpx;
+    font-size: $font-size-base;
     font-weight: 600;
     color: $text-color-primary;
     margin-bottom: 20rpx;
-    padding-bottom: 16rpx;
-    border-bottom: 1rpx solid $bg-color-secondary;
   }
-  
-  .info-item {
-    display: flex;
-    justify-content: space-between;
-    padding: 14rpx 0;
-    
-    .label {
-      font-size: 28rpx;
-      color: $text-color-tertiary;
-    }
-    
-    .value {
-      font-size: 28rpx;
-      color: $text-color-primary;
+
+  .info-grid {
+    .info-item {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 14rpx 0;
+
+      .label {
+        font-size: $font-size-base;
+        color: $text-color-muted;
+      }
+
+      .value {
+        font-size: $font-size-base;
+        color: $text-color-primary;
+        text-align: right;
+        max-width: 60%;
+      }
     }
   }
-  
+
   .sales-grid {
     display: flex;
-    background: $bg-color-page;
-    border-radius: 12rpx;
+    background: #FAFAFA;
+    border-radius: $radius-base;
     padding: 20rpx 0;
-    
+
     .sales-item {
       flex: 1;
       display: flex;
       flex-direction: column;
       align-items: center;
       justify-content: center;
-      
+
       .sales-value {
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        font-size: 30rpx;
+        font-size: $font-size-md;
         font-weight: 700;
         color: $text-color-primary;
         margin-bottom: 8rpx;
-        
-        &.accent {
-          color: $accent-color;
-        }
-        
-        &.primary {
-          color: $primary-color;
-        }
       }
-      
+
       .sales-label {
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        font-size: 22rpx;
-        color: $text-color-tertiary;
+        font-size: $font-size-xs;
+        color: $text-color-muted;
       }
     }
   }
-  
+
   .device-list {
     .device-item {
       display: flex;
       justify-content: space-between;
       align-items: center;
-      padding: 20rpx 0;
-      border-bottom: 1rpx solid $bg-color-secondary;
-      
-      &:last-child {
-        border-bottom: none;
+      padding: 18rpx 0;
+
+      & + .device-item {
+        border-top: 1rpx solid #FAFAFA;
       }
-      
+
       .device-info {
         .device-name {
           display: block;
-          font-size: 28rpx;
+          font-size: $font-size-base;
           color: $text-color-primary;
           font-weight: 500;
           margin-bottom: 4rpx;
         }
-        
+
         .device-no {
-          font-size: 24rpx;
+          font-size: 22rpx;
           color: $text-color-muted;
-          font-family: monospace;
         }
       }
-      
+
       .device-status {
         display: flex;
         align-items: center;
         gap: 6rpx;
-        padding: 8rpx 16rpx;
-        border-radius: 8rpx;
-        font-size: 22rpx;
+        padding: 6rpx 14rpx;
+        border-radius: $radius-full;
+        font-size: 20rpx;
         font-weight: 500;
-        
+        flex-shrink: 0;
+
         .status-dot {
-          width: 8rpx;
-          height: 8rpx;
+          width: 10rpx;
+          height: 10rpx;
           border-radius: 50%;
         }
-        
+
         &.online {
           background: $success-color-bg;
-          color: $primary-color;
-          .status-dot { background: $primary-color; }
+          color: $success-color;
+          .status-dot { background: $success-color; }
         }
-        
+
         &.offline {
-          background: $accent-color-bg;
-          color: $accent-color;
-          .status-dot { background: $accent-color; }
+          background: $bg-color-secondary;
+          color: $text-color-muted;
+          .status-dot { background: $text-color-placeholder; }
         }
-        
+
         &.maintenance {
-          background: $primary-color-bg;
-          color: $primary-color;
-          .status-dot { background: $primary-color; }
+          background: $warning-color-bg;
+          color: $warning-color;
+          .status-dot { background: $warning-color; }
         }
       }
     }

+ 112 - 159
haha-admin-mp/src/pages/shop/list.vue

@@ -1,80 +1,71 @@
 <template>
   <view class="page">
-    <!-- 导航栏 -->
     <NavBar title="门店管理" :showBack="true" />
-    
-    <!-- 门店列表 -->
-    <scroll-view 
-      class="shop-scroll" 
-      scroll-y 
+
+    <scroll-view
+      class="shop-scroll"
+      scroll-y
       @scrolltolower="loadMore"
     >
       <view class="shop-list">
-        <view 
-          class="shop-card" 
-          v-for="shop in shopList" 
+        <view
+          class="shop-card"
+          v-for="shop in shopList"
           :key="shop.id"
           @click="goDetail(shop.id)"
         >
           <view class="card-header">
             <view class="shop-info">
               <text class="shop-name">{{ shop.name }}</text>
-              <text class="shop-no">ID: {{ shop.id }}</text>
+              <text class="shop-no">{{ shop.id }}</text>
             </view>
             <view class="status-badge" :class="getStatusClass(shop.status)">
               <view class="status-dot"></view>
               <text>{{ getStatusText(shop.status) }}</text>
             </view>
           </view>
-          
+
           <view class="card-body">
             <view class="info-row">
-              <view class="info-icon location">
-                <view class="icon-inner"></view>
-              </view>
+              <text class="info-label">地址</text>
               <text class="info-text">{{ shop.address }}</text>
             </view>
             <view class="info-row">
-              <view class="info-icon contact">
-                <view class="icon-inner"></view>
-              </view>
-              <text class="info-text">{{ shop.contactName }} {{ shop.contactPhone }}</text>
+              <text class="info-label">联系人</text>
+              <text class="info-text">{{ shop.contactName }}  {{ shop.contactPhone }}</text>
             </view>
           </view>
-          
+
           <view class="card-stats">
             <view class="stat-item">
               <text class="stat-value">{{ shop.deviceCount }}</text>
               <text class="stat-label">设备数</text>
             </view>
-            <view class="stat-divider"></view>
             <view class="stat-item">
-              <text class="stat-value accent">¥{{ formatMoney(shop.todaySales) }}</text>
+              <text class="stat-value">¥{{ formatMoney(shop.todaySales) }}</text>
               <text class="stat-label">今日销售</text>
             </view>
-            <view class="stat-divider"></view>
             <view class="stat-item">
-              <text class="stat-value primary">¥{{ formatMoney(shop.monthSales) }}</text>
+              <text class="stat-value">¥{{ formatMoney(shop.monthSales) }}</text>
               <text class="stat-label">本月销售</text>
             </view>
           </view>
         </view>
       </view>
-      
+
       <view class="loading-more" v-if="loading">
         <view class="loading-spinner"></view>
         <text>加载中...</text>
       </view>
-      
+
       <view class="no-more" v-if="!hasMore && shopList.length > 0">
-        <text>没有更多了</text>
+        <text>没有更多了</text>
       </view>
-      
+
       <view class="empty-state" v-if="!loading && shopList.length === 0">
-        <view class="empty-icon">
-          <view class="empty-icon-inner"></view>
-        </view>
-        <text class="empty-text">暂无门店数据</text>
+        <view class="empty-icon"></view>
+        <text class="empty-title">暂无门店</text>
+        <text class="empty-desc">门店数据将在此处展示</text>
       </view>
     </scroll-view>
   </view>
@@ -148,26 +139,25 @@ onMounted(() => {
   flex-direction: column;
 }
 
-/* 门店列表 */
 .shop-scroll {
   flex: 1;
   height: 0;
 }
 
 .shop-list {
-  padding: 16rpx 24rpx;
+  padding: $spacing-2 $spacing-3;
 }
 
 .shop-card {
   background: $bg-color-card;
-  border: 1rpx solid $border-color;
-  border-radius: 16rpx;
-  margin-bottom: 12rpx;
+  border-radius: $radius-lg;
+  margin-bottom: $spacing-2;
   overflow: hidden;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
   transition: transform 0.15s;
-  
+
   &:active {
-    transform: scale(0.98);
+    transform: scale(0.985);
   }
 }
 
@@ -175,102 +165,80 @@ onMounted(() => {
   display: flex;
   justify-content: space-between;
   align-items: flex-start;
-  padding: 20rpx 24rpx 16rpx;
-  
+  padding: $spacing-3 $spacing-3 $spacing-2;
+
   .shop-info {
+    flex: 1;
+
     .shop-name {
       display: block;
-      font-size: 30rpx;
+      font-size: $font-size-lg;
       font-weight: 600;
       color: $text-color-primary;
       margin-bottom: 4rpx;
+      line-height: 1.3;
     }
-    
+
     .shop-no {
-      font-size: 22rpx;
+      font-size: $font-size-xs;
       color: $text-color-muted;
-      font-family: monospace;
     }
   }
-  
+
   .status-badge {
     display: flex;
     align-items: center;
     justify-content: center;
     gap: 6rpx;
-    padding: 8rpx 16rpx;
-    border-radius: 8rpx;
-    font-size: 22rpx;
+    padding: 6rpx 14rpx;
+    border-radius: $radius-full;
+    font-size: 20rpx;
     font-weight: 500;
-    
+    flex-shrink: 0;
+
     .status-dot {
-      width: 8rpx;
-      height: 8rpx;
+      width: 10rpx;
+      height: 10rpx;
       border-radius: 50%;
     }
-    
+
     &.active {
       background: $success-color-bg;
-      color: $primary-color;
-      .status-dot { background: $primary-color; }
+      color: $success-color;
+      .status-dot { background: $success-color; }
     }
-    
+
     &.inactive {
-      background: $accent-color-bg;
-      color: $accent-color;
-      .status-dot { background: $accent-color; }
+      background: $bg-color-secondary;
+      color: $text-color-muted;
+      .status-dot { background: $text-color-placeholder; }
     }
   }
 }
 
 .card-body {
-  padding: 0 24rpx 16rpx;
-  
+  padding: 0 $spacing-3 $spacing-2;
+
   .info-row {
     display: flex;
-    align-items: center;
-    margin-bottom: 8rpx;
-    
+    align-items: flex-start;
+    margin-bottom: 10rpx;
+
     &:last-child {
       margin-bottom: 0;
     }
-    
-    .info-icon {
-      width: 28rpx;
-      height: 28rpx;
-      border-radius: 6rpx;
-      margin-right: 10rpx;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      
-      &.location {
-        background: $success-color-bg;
-        
-        .icon-inner {
-          width: 10rpx;
-          height: 10rpx;
-          background: $primary-color;
-          border-radius: 50% 50% 50% 0;
-          transform: rotate(-45deg);
-        }
-      }
-      
-      &.contact {
-        background: $info-color-bg;
-        
-        .icon-inner {
-          width: 12rpx;
-          height: 12rpx;
-          background: $info-color;
-          border-radius: 50%;
-        }
-      }
+
+    .info-label {
+      font-size: 24rpx;
+      color: $text-color-muted;
+      flex-shrink: 0;
+      margin-right: $spacing-1;
     }
-    
+
     .info-text {
       font-size: 26rpx;
       color: $text-color-secondary;
+      line-height: 1.5;
     }
   }
 }
@@ -278,68 +246,46 @@ onMounted(() => {
 .card-stats {
   display: flex;
   align-items: center;
-  background: $bg-color-page;
-  border-top: 1rpx solid $bg-color-secondary;
-  padding: 16rpx 12rpx;
-  
+  background: #FAFAFA;
+  padding: 16rpx 8rpx;
+
   .stat-item {
     flex: 1;
     display: flex;
     flex-direction: column;
     align-items: center;
     justify-content: center;
-    
+
     .stat-value {
-      display: flex;
-      align-items: center;
-      justify-content: center;
       font-size: 26rpx;
       font-weight: 600;
       color: $text-color-primary;
       margin-bottom: 4rpx;
-      
-      &.accent {
-        color: $accent-color;
-      }
-      
-      &.primary {
-        color: $primary-color;
-      }
     }
-    
+
     .stat-label {
-      display: flex;
-      align-items: center;
-      justify-content: center;
       font-size: 20rpx;
       color: $text-color-muted;
     }
   }
-  
-  .stat-divider {
-    width: 1rpx;
-    height: 40rpx;
-    background: $border-color;
-  }
 }
 
-/* 加载状态 */
 .loading-more {
   display: flex;
   align-items: center;
   justify-content: center;
   gap: 12rpx;
-  padding: 32rpx;
+  padding: 40rpx;
   color: $text-color-muted;
   font-size: 24rpx;
-  
+
   .loading-spinner {
     width: 32rpx;
     height: 32rpx;
     border: 3rpx solid $border-color;
-    border-top-color: $primary-color;
+    border-top-color: $text-color-muted;
     border-radius: 50%;
-    animation: spin 1s linear infinite;
+    animation: spin 0.8s linear infinite;
   }
 }
 
@@ -349,7 +295,7 @@ onMounted(() => {
 
 .no-more {
   text-align: center;
-  padding: 32rpx;
+  padding: 40rpx;
   font-size: 24rpx;
   color: $text-color-placeholder;
 }
@@ -358,42 +304,49 @@ onMounted(() => {
   display: flex;
   flex-direction: column;
   align-items: center;
-  padding: 100rpx 0;
-  
+  padding: 120rpx 0 80rpx;
+
   .empty-icon {
-    width: 120rpx;
-    height: 120rpx;
+    width: 100rpx;
+    height: 100rpx;
+    border-radius: 50%;
     background: $bg-color-secondary;
-    border-radius: 24rpx;
-    margin-bottom: 20rpx;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    
-    .empty-icon-inner {
-      width: 40rpx;
-      height: 32rpx;
+    margin-bottom: 24rpx;
+    position: relative;
+
+    &::before,
+    &::after {
+      content: '';
+      position: absolute;
+      left: 50%;
+      transform: translateX(-50%);
       background: $text-color-placeholder;
-      border-radius: 0 0 8rpx 8rpx;
-      position: relative;
-      
-      &::before {
-        content: '';
-        position: absolute;
-        top: -12rpx;
-        left: 50%;
-        transform: translateX(-50%);
-        width: 0;
-        height: 0;
-        border-left: 24rpx solid transparent;
-        border-right: 24rpx solid transparent;
-        border-bottom: 14rpx solid $text-color-placeholder;
-      }
+    }
+
+    &::before {
+      top: 32rpx;
+      width: 28rpx;
+      height: 28rpx;
+      border-radius: 50%;
+    }
+
+    &::after {
+      top: 68rpx;
+      width: 44rpx;
+      height: 6rpx;
+      border-radius: 3rpx;
     }
   }
-  
-  .empty-text {
-    font-size: 28rpx;
+
+  .empty-title {
+    font-size: $font-size-base;
+    font-weight: 500;
+    color: $text-color-secondary;
+    margin-bottom: 8rpx;
+  }
+
+  .empty-desc {
+    font-size: $font-size-sm;
     color: $text-color-muted;
   }
 }

+ 671 - 0
haha-admin-mp/src/pages/staff/form.vue

@@ -0,0 +1,671 @@
+<template>
+  <view class="page">
+    <NavBar :title="isEdit ? '编辑人员' : '新增人员'" :showBack="true" @back="goBack" />
+
+    <scroll-view class="form-scroll" scroll-y>
+      <!-- 编辑模式:头像区 -->
+      <view class="avatar-card" v-if="isEdit">
+        <view class="ac-avatar" :class="form.status === 2 ? 'off' : ''">
+          <text>{{ (form.realName || form.username || '?')[0] }}</text>
+        </view>
+        <view class="ac-info">
+          <text class="ac-name">{{ form.realName || form.username }}</text>
+          <text class="ac-username">@{{ form.username }}</text>
+          <view class="ac-status" :class="form.status === 1 ? 'on' : 'off'">
+            <view class="ac-dot" :class="form.status === 1 ? 'on' : 'off'"></view>
+            <text>{{ form.status === 1 ? '已启用' : '已停用' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 表单卡片 -->
+      <view class="form-card">
+        <!-- 基本信息 -->
+        <view class="section-title">基本信息</view>
+
+        <view class="field">
+          <text class="field-label">用户名<text class="required">*</text></text>
+          <input
+            class="field-input"
+            v-model="form.username"
+            placeholder="登录账号"
+            placeholder-class="field-ph"
+            :disabled="isEdit"
+          />
+          <text class="field-hint" v-if="isEdit">用户名不可修改</text>
+        </view>
+
+        <view class="field" v-if="!isEdit">
+          <text class="field-label">登录密码<text class="required">*</text></text>
+          <input
+            class="field-input"
+            v-model="form.password"
+            placeholder="设置登录密码"
+            placeholder-class="field-ph"
+            password
+          />
+        </view>
+
+        <view class="field">
+          <text class="field-label">真实姓名<text class="required">*</text></text>
+          <input
+            class="field-input"
+            v-model="form.realName"
+            placeholder="请输入真实姓名"
+            placeholder-class="field-ph"
+          />
+        </view>
+
+        <view class="field">
+          <text class="field-label">手机号<text class="required">*</text></text>
+          <input
+            class="field-input"
+            v-model="form.phone"
+            placeholder="请输入手机号"
+            placeholder-class="field-ph"
+            type="number"
+            maxlength="11"
+          />
+        </view>
+
+        <view class="field">
+          <text class="field-label">邮箱</text>
+          <input
+            class="field-input"
+            v-model="form.email"
+            placeholder="选填"
+            placeholder-class="field-ph"
+          />
+        </view>
+      </view>
+
+      <!-- 权限与状态 -->
+      <view class="form-card">
+        <view class="section-title">权限与状态</view>
+
+        <view class="field" @click="showRolePicker">
+          <text class="field-label">角色<text class="required">*</text></text>
+          <view class="field-picker">
+            <text class="picker-text" :class="{ placeholder: !selectedRoleName }">
+              {{ selectedRoleName || '请选择角色' }}
+            </text>
+            <view class="picker-arrow"></view>
+          </view>
+          <text class="field-hint">分配角色以授予对应的操作权限</text>
+        </view>
+
+        <view class="field">
+          <text class="field-label">账号状态</text>
+          <view class="field-switch-row">
+            <view
+              class="switch-opt"
+              :class="{ on: form.status === 1 }"
+              @click="form.status = 1"
+            >
+              <view class="so-dot on"></view>
+              <text>启用</text>
+            </view>
+            <view
+              class="switch-opt"
+              :class="{ on: form.status === 2 }"
+              @click="form.status = 2"
+            >
+              <view class="so-dot off"></view>
+              <text>停用</text>
+            </view>
+          </view>
+        </view>
+
+        <view class="field">
+          <text class="field-label">备注</text>
+          <textarea
+            class="field-textarea"
+            v-model="form.remark"
+            placeholder="备注信息(选填)"
+            placeholder-class="field-ph"
+            :maxlength="200"
+          />
+          <text class="field-hint right">{{ form.remark ? form.remark.length : 0 }}/200</text>
+        </view>
+      </view>
+
+      <!-- 操作按钮 -->
+      <view class="btn-area">
+        <view class="btn-save" @click="handleSave">
+          <text class="btn-save-text">{{ isEdit ? '保存修改' : '创建人员' }}</text>
+        </view>
+        <view class="btn-reset" v-if="isEdit" @click="handleResetPwd">
+          <text class="btn-reset-text">重置密码</text>
+        </view>
+        <view class="btn-delete" v-if="isEdit" @click="handleDelete">
+          <text class="btn-delete-text">删除人员</text>
+        </view>
+      </view>
+
+      <view class="bottom-safe"></view>
+    </scroll-view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import {
+  addStaff,
+  updateStaff,
+  getStaffById,
+  deleteStaff,
+  resetStaffPassword,
+  getAllRoles,
+  type RoleOption,
+  type StaffForm
+} from '@/api/staff';
+
+const isEdit = ref(false);
+const editId = ref<number | null>(null);
+const roleOptions = ref<RoleOption[]>([]);
+const saving = ref(false);
+
+const form = reactive<StaffForm>({
+  username: '',
+  password: '',
+  realName: '',
+  phone: '',
+  email: '',
+  roleIds: '',
+  status: 1,
+  remark: ''
+});
+
+const selectedRoleName = ref('');
+
+const loadRoles = async () => {
+  try {
+    roleOptions.value = await getAllRoles();
+  } catch (e) {
+    // ignore
+  }
+};
+
+const loadDetail = async (id: number) => {
+  try {
+    const data = await getStaffById(id);
+    if (data) {
+      form.id = data.id;
+      form.username = data.username || '';
+      form.realName = data.realName || '';
+      form.phone = data.phone || '';
+      form.email = data.email || '';
+      form.roleIds = data.roleIds ? data.roleIds.split(',')[0] : '';
+      form.status = data.status || 1;
+      form.remark = data.remark || '';
+
+      if (data.roleIds && roleOptions.value.length > 0) {
+        const firstRoleId = data.roleIds.split(',')[0];
+        const role = roleOptions.value.find(r => String(r.id) === String(firstRoleId));
+        if (role) selectedRoleName.value = role.name;
+      }
+    }
+  } catch (e) {
+    uni.showToast({ title: '加载失败', icon: 'none' });
+  }
+};
+
+const showRolePicker = () => {
+  if (roleOptions.value.length === 0) {
+    uni.showToast({ title: '暂无可用角色', icon: 'none' });
+    return;
+  }
+  const names = roleOptions.value.map(r => r.name);
+  uni.showActionSheet({
+    itemList: names,
+    itemColor: '#1E293B',
+    success: (res) => {
+      const role = roleOptions.value[res.tapIndex];
+      if (role) {
+        form.roleIds = String(role.id);
+        selectedRoleName.value = role.name;
+      }
+    }
+  });
+};
+
+const validate = (): string | null => {
+  if (!form.username || !form.username.trim()) return '请输入用户名';
+  if (!isEdit.value && (!form.password || !form.password.trim())) return '请输入登录密码';
+  if (!form.realName || !form.realName.trim()) return '请输入真实姓名';
+  if (!form.phone || !form.phone.trim()) return '请输入手机号';
+  if (!/^1[3-9]\d{9}$/.test(form.phone.trim())) return '请输入正确的手机号';
+  if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) return '请输入正确的邮箱';
+  if (!form.roleIds) return '请选择角色';
+  return null;
+};
+
+const handleSave = async () => {
+  if (saving.value) return;
+  const err = validate();
+  if (err) {
+    uni.showToast({ title: err, icon: 'none' });
+    return;
+  }
+  saving.value = true;
+  uni.showLoading({ title: '保存中...', mask: true });
+
+  try {
+    const data: StaffForm = { ...form };
+    if (isEdit.value) {
+      delete data.password;
+      await updateStaff(data);
+    } else {
+      await addStaff(data);
+    }
+    uni.hideLoading();
+    uni.showToast({ title: isEdit.value ? '修改成功' : '新增成功', icon: 'success' });
+    setTimeout(() => {
+      uni.navigateBack();
+    }, 1200);
+  } catch (e) {
+    uni.hideLoading();
+    uni.showToast({ title: '保存失败', icon: 'none' });
+  } finally {
+    saving.value = false;
+  }
+};
+
+const handleResetPwd = () => {
+  uni.showModal({
+    title: '重置密码',
+    content: '确认重置该人员的登录密码?密码将重置为默认值。',
+    success: async (res) => {
+      if (!res.confirm) return;
+      try {
+        await resetStaffPassword(editId.value!, '123456');
+        uni.showToast({ title: '密码已重置', icon: 'success' });
+      } catch (e) {
+        uni.showToast({ title: '操作失败', icon: 'none' });
+      }
+    }
+  });
+};
+
+const handleDelete = () => {
+  uni.showModal({
+    title: '确认删除',
+    content: `确认删除人员「${form.realName || form.username}」?删除后不可恢复。`,
+    success: async (res) => {
+      if (!res.confirm) return;
+      try {
+        await deleteStaff(editId.value!);
+        uni.showToast({ title: '已删除', icon: 'success' });
+        setTimeout(() => {
+          uni.navigateBack();
+        }, 1200);
+      } catch (e) {
+        uni.showToast({ title: '删除失败', icon: 'none' });
+      }
+    }
+  });
+};
+
+const goBack = () => {
+  uni.navigateBack();
+};
+
+onMounted(async () => {
+  const pages = getCurrentPages();
+  const page = pages[pages.length - 1];
+  const options = (page as any).options || {};
+  const id = options.id ? Number(options.id) : null;
+
+  await loadRoles();
+
+  if (id) {
+    isEdit.value = true;
+    editId.value = id;
+    uni.setNavigationBarTitle({ title: '编辑人员' });
+    await loadDetail(id);
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+$font-stack: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', system-ui, sans-serif;
+
+.page {
+  height: 100vh;
+  width: 100vw;
+  overflow-x: hidden;
+  background: $bg-color-page;
+  display: flex;
+  flex-direction: column;
+  font-family: $font-stack;
+}
+
+// ====== Scroll ======
+.form-scroll {
+  flex: 1;
+  width: 100%;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+
+// ====== Avatar card (edit mode) ======
+.avatar-card {
+  display: flex;
+  align-items: center;
+  background: $bg-color-card;
+  border-radius: 20rpx;
+  padding: 32rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+  gap: 24rpx;
+  overflow: hidden;
+}
+
+.ac-avatar {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 50%;
+  background: $primary-color-bg;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+
+  text {
+    font-size: 36rpx;
+    font-weight: 700;
+    color: $primary-color-dark;
+  }
+
+  &.off {
+    background: $bg-color-page;
+    text { color: $text-color-placeholder; }
+  }
+}
+
+.ac-info {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 8rpx;
+}
+
+.ac-name {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: $text-color-primary;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.ac-username {
+  font-size: 24rpx;
+  color: $text-color-muted;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.ac-status {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  margin-top: 8rpx;
+
+  text { font-size: 24rpx; font-weight: 500; }
+  &.on text { color: $success-color; }
+  &.off text { color: $text-color-muted; }
+}
+
+.ac-dot {
+  width: 12rpx; height: 12rpx; border-radius: 50%;
+  flex-shrink: 0;
+  &.on { background: $success-color; }
+  &.off { background: $text-color-placeholder; }
+}
+
+// ====== Form card ======
+.form-card {
+  background: $bg-color-card;
+  border-radius: 20rpx;
+  padding: 32rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+  overflow: hidden;
+}
+
+.section-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: $text-color-primary;
+  margin-bottom: 16rpx;
+  letter-spacing: 2rpx;
+}
+
+// ====== Field ======
+.field {
+  padding: 24rpx 0;
+
+  &:first-of-type {
+    padding-top: 16rpx;
+  }
+
+  &:not(:last-of-type) {
+    border-bottom: 1rpx solid $border-color-light;
+  }
+
+  &:last-child {
+    padding-bottom: 0;
+  }
+}
+
+.field-label {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: $text-color-primary;
+  margin-bottom: 16rpx;
+  display: block;
+}
+
+.required {
+  color: $error-color;
+  margin-left: 4rpx;
+  font-weight: 400;
+}
+
+.field-input {
+  font-size: 28rpx;
+  color: $text-color-primary;
+  background: $bg-color-page;
+  border-radius: 12rpx;
+  padding: 0 24rpx;
+  height: 80rpx;
+  line-height: 80rpx;
+  width: 100%;
+  box-sizing: border-box;
+
+  &[disabled] {
+    color: $text-color-muted;
+    background: $bg-color-secondary;
+  }
+}
+
+.field-ph {
+  color: $text-color-placeholder;
+  font-size: 24rpx;
+}
+
+.field-textarea {
+  font-size: 28rpx;
+  color: $text-color-primary;
+  background: $bg-color-page;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  width: 100%;
+  box-sizing: border-box;
+  min-height: 180rpx;
+}
+
+.field-hint {
+  font-size: 22rpx;
+  color: $text-color-muted;
+  margin-top: 12rpx;
+  display: block;
+  line-height: 1.5;
+
+  &.right {
+    text-align: right;
+  }
+}
+
+// ====== Role picker ======
+.field-picker {
+  display: flex;
+  align-items: center;
+  background: $bg-color-page;
+  border-radius: 12rpx;
+  padding: 0 24rpx;
+  height: 80rpx;
+  transition: opacity 0.15s ease;
+  overflow: hidden;
+
+  &:active { opacity: 0.7; }
+}
+
+.picker-text {
+  flex: 1;
+  font-size: 28rpx;
+  color: $text-color-primary;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+
+  &.placeholder {
+    color: $text-color-placeholder;
+  }
+}
+
+.picker-arrow {
+  width: 12rpx; height: 12rpx;
+  border-top: 3rpx solid $text-color-placeholder;
+  border-right: 3rpx solid $text-color-placeholder;
+  transform: rotate(45deg);
+  flex-shrink: 0;
+  margin-left: 8rpx;
+}
+
+// ====== Status switch ======
+.field-switch-row {
+  display: flex;
+  gap: 16rpx;
+}
+
+.switch-opt {
+  flex: 1;
+  min-width: 0;
+  padding: 24rpx 0;
+  border-radius: 16rpx;
+  background: $bg-color-page;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10rpx;
+  transition: background 0.2s ease;
+
+  text {
+    font-size: 28rpx;
+    font-weight: 500;
+    color: $text-color-muted;
+    transition: color 0.2s ease;
+  }
+
+  &.on {
+    background: $primary-color-bg;
+    box-shadow: inset 0 0 0 2rpx rgba(255, 193, 7, 0.2);
+    text { color: $primary-color-dark; font-weight: 600; }
+  }
+
+  &:active { opacity: 0.7; }
+}
+
+.so-dot {
+  width: 16rpx; height: 16rpx; border-radius: 50%;
+  flex-shrink: 0;
+  &.on { background: $success-color; box-shadow: 0 0 8rpx rgba(16, 185, 129, 0.3); }
+  &.off { background: $text-color-placeholder; }
+}
+
+// ====== Buttons ======
+.btn-area {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+  margin-top: 16rpx;
+  align-items: center;
+}
+
+.btn-save {
+  width: 100%;
+  background: $primary-color;
+  border-radius: 48rpx;
+  padding: 28rpx 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 4rpx 16rpx rgba(255, 193, 7, 0.3);
+  transition: opacity 0.15s ease;
+
+  &:active { opacity: 0.8; }
+
+  .btn-save-text {
+    font-size: 32rpx;
+    font-weight: 600;
+    color: $text-color-primary;
+  }
+}
+
+.btn-reset {
+  width: 100%;
+  background: $bg-color-card;
+  border-radius: 48rpx;
+  padding: 24rpx 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: 1rpx solid $border-color;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+  transition: opacity 0.15s ease;
+
+  &:active { opacity: 0.7; }
+
+  .btn-reset-text {
+    font-size: 28rpx;
+    font-weight: 500;
+    color: $info-color;
+  }
+}
+
+.btn-delete {
+  width: 100%;
+  padding: 24rpx 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: opacity 0.15s ease;
+
+  &:active { opacity: 0.7; }
+
+  .btn-delete-text {
+    font-size: 28rpx;
+    font-weight: 500;
+    color: $error-color;
+  }
+}
+
+.bottom-safe {
+  height: 64rpx;
+}
+</style>

+ 685 - 0
haha-admin-mp/src/pages/staff/list.vue

@@ -0,0 +1,685 @@
+<template>
+  <view class="page">
+    <NavBar title="人员管理" :showBack="true" @back="goBack" />
+
+    <!-- 搜索 -->
+    <view class="search-bar">
+      <view class="search-box">
+        <view class="search-icon">
+          <view class="si-ring"></view>
+          <view class="si-stem"></view>
+        </view>
+        <input
+          class="search-input"
+          v-model="keyword"
+          placeholder="用户名 / 手机号"
+          placeholder-class="search-ph"
+          confirm-type="search"
+          @confirm="handleSearch"
+        />
+      </view>
+      <view class="search-btn" @click="handleSearch">
+        <text class="search-btn-text">搜索</text>
+      </view>
+    </view>
+
+    <!-- 统计 -->
+    <view class="stats" v-if="statistics">
+      <view class="stat-item">
+        <text class="stat-num">{{ statistics.total || 0 }}</text>
+        <text class="stat-label">总数</text>
+      </view>
+      <view class="stat-divider"></view>
+      <view class="stat-item">
+        <text class="stat-num active">{{ statistics.active || 0 }}</text>
+        <text class="stat-label">启用</text>
+      </view>
+      <view class="stat-divider"></view>
+      <view class="stat-item">
+        <text class="stat-num disabled">{{ statistics.disabled || 0 }}</text>
+        <text class="stat-label">停用</text>
+      </view>
+    </view>
+
+    <!-- 列表 -->
+    <scroll-view
+      class="list-scroll"
+      scroll-y
+      @scrolltolower="loadMore"
+      refresher-enabled
+      :refresher-triggered="refreshing"
+      @refresherrefresh="onRefresh"
+    >
+      <view class="card-set" v-if="staffList.length > 0">
+        <view
+          class="card"
+          v-for="item in staffList"
+          :key="item.id"
+          @click="goForm(item.id)"
+          @longpress="showActions(item)"
+        >
+          <view class="card-avatar" :class="item.status === 2 ? 'off' : ''">
+            <text>{{ (item.realName || item.username || '?')[0] }}</text>
+          </view>
+
+          <view class="card-info">
+            <view class="info-top">
+              <text class="info-name">{{ item.realName || item.username }}</text>
+              <view class="info-status" :class="item.status === 1 ? 'on' : 'off'">
+                <view class="info-dot" :class="item.status === 1 ? 'on' : 'off'"></view>
+                <text>{{ item.status === 1 ? '启用' : '停用' }}</text>
+              </view>
+            </view>
+            <view class="info-sub">
+              <text class="sub-username">@{{ item.username }}</text>
+              <text class="sub-sep" v-if="item.phone">·</text>
+              <text class="sub-phone" v-if="item.phone">{{ item.phone }}</text>
+            </view>
+            <view class="info-role" v-if="getRoleName(item.roleIds)">
+              <text class="role-tag">{{ getRoleName(item.roleIds) }}</text>
+            </view>
+          </view>
+
+          <view class="card-arrow">
+            <view class="arr-inner"></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 && staffList.length > 0">
+        <view class="end-line"></view>
+        <text class="end-text">没有更多了</text>
+        <view class="end-line"></view>
+      </view>
+
+      <!-- 空状态 -->
+      <view class="empty" v-if="!loading && staffList.length === 0">
+        <view class="empty-icon">
+          <view class="empty-person">
+            <view class="ep-head"></view>
+            <view class="ep-body"></view>
+          </view>
+        </view>
+        <text class="empty-title">暂无人员</text>
+        <text class="empty-desc">点击右下角按钮新增管理员</text>
+      </view>
+    </scroll-view>
+
+    <!-- 新增按钮 -->
+    <view class="fab" @click="goForm()">
+      <text class="fab-text">+</text>
+    </view>
+
+    <CustomTabBar />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import NavBar from '@/components/NavBar.vue';
+import CustomTabBar from '@/components/CustomTabBar.vue';
+import {
+  getStaffList,
+  getStaffStatistics,
+  deleteStaff,
+  resetStaffPassword,
+  updateStaff,
+  getAllRoles,
+  type StaffQuery,
+  type RoleOption
+} from '@/api/staff';
+
+const staffList = ref<any[]>([]);
+const loading = ref(false);
+const hasMore = ref(true);
+const page = ref(1);
+const pageSize = 10;
+const keyword = ref('');
+const refreshing = ref(false);
+const statistics = ref<any>(null);
+const roleMap = ref<Record<string, string>>({});
+
+const fetchRoles = async () => {
+  try {
+    const roles: RoleOption[] = await getAllRoles();
+    roleMap.value = {};
+    roles.forEach(r => {
+      roleMap.value[String(r.id)] = r.name;
+    });
+  } catch (e) {
+    // ignore
+  }
+};
+
+const getRoleName = (roleIds: string): string => {
+  if (!roleIds) return '';
+  const ids = roleIds.split(',');
+  return ids.map(id => roleMap.value[String(id)] || '').filter(Boolean).join(',');
+};
+
+const fetchList = async () => {
+  if (loading.value) return;
+  loading.value = true;
+  try {
+    const params: StaffQuery = { page: page.value, pageSize };
+    if (keyword.value) {
+      // 判断输入是手机号还是用户名
+      if (/^1[3-9]\d{9}$/.test(keyword.value)) {
+        params.phone = keyword.value;
+      } else {
+        params.username = keyword.value;
+      }
+    }
+    const res = await getStaffList(params);
+    if (page.value === 1) {
+      staffList.value = res.list || [];
+    } else {
+      staffList.value = [...staffList.value, ...(res.list || [])];
+    }
+    hasMore.value = staffList.value.length < (res.total || 0);
+  } catch (e) {
+    // ignore
+  } finally {
+    loading.value = false;
+    refreshing.value = false;
+  }
+};
+
+const fetchStats = async () => {
+  try {
+    statistics.value = await getStaffStatistics();
+  } catch (e) {
+    // ignore
+  }
+};
+
+const loadMore = () => {
+  if (loading.value || !hasMore.value) return;
+  page.value++;
+  fetchList();
+};
+
+const onRefresh = () => {
+  refreshing.value = true;
+  page.value = 1;
+  hasMore.value = true;
+  fetchList();
+  fetchStats();
+};
+
+const handleSearch = () => {
+  page.value = 1;
+  hasMore.value = true;
+  fetchList();
+};
+
+const goForm = (id?: number) => {
+  const url = id != null ? `/pages/staff/form?id=${id}` : '/pages/staff/form';
+  uni.navigateTo({ url });
+};
+
+const goBack = () => {
+  uni.navigateBack();
+};
+
+const showActions = (item: any) => {
+  const isActive = item.status === 1;
+  uni.showActionSheet({
+    itemList: ['编辑信息', '重置密码', isActive ? '停用账号' : '启用账号', '删除人员'],
+    itemColor: '#1E293B',
+    success: (res) => {
+      switch (res.tapIndex) {
+        case 0:
+          goForm(item.id);
+          break;
+        case 1:
+          handleResetPassword(item);
+          break;
+        case 2:
+          handleToggleStatus(item);
+          break;
+        case 3:
+          handleDelete(item);
+          break;
+      }
+    }
+  });
+};
+
+const handleToggleStatus = (item: any) => {
+  const newStatus = item.status === 1 ? 2 : 1;
+  const actionText = newStatus === 2 ? '停用' : '启用';
+  uni.showModal({
+    title: '确认操作',
+    content: `确认${actionText}人员「${item.realName || item.username}」?`,
+    success: async (res) => {
+      if (!res.confirm) return;
+      try {
+        await updateStaff({ id: item.id, status: newStatus } as any);
+        uni.showToast({ title: `已${actionText}`, icon: 'success' });
+        page.value = 1;
+        fetchList();
+        fetchStats();
+      } catch (e) {
+        uni.showToast({ title: '操作失败', icon: 'none' });
+      }
+    }
+  });
+};
+
+const handleResetPassword = (item: any) => {
+  uni.showModal({
+    title: '重置密码',
+    content: `确认重置「${item.realName || item.username}」的登录密码?\n密码将重置为默认值。`,
+    success: async (res) => {
+      if (!res.confirm) return;
+      try {
+        await resetStaffPassword(item.id, '123456');
+        uni.showToast({ title: '密码已重置', icon: 'success' });
+      } catch (e) {
+        uni.showToast({ title: '操作失败', icon: 'none' });
+      }
+    }
+  });
+};
+
+const handleDelete = (item: any) => {
+  uni.showModal({
+    title: '确认删除',
+    content: `确认删除人员「${item.realName || item.username}」?\n删除后不可恢复。`,
+    success: async (res) => {
+      if (!res.confirm) return;
+      try {
+        await deleteStaff(item.id);
+        uni.showToast({ title: '已删除', icon: 'success' });
+        page.value = 1;
+        fetchList();
+        fetchStats();
+      } catch (e) {
+        uni.showToast({ title: '删除失败', icon: 'none' });
+      }
+    }
+  });
+};
+
+onMounted(() => {
+  fetchRoles();
+  fetchList();
+  fetchStats();
+});
+</script>
+
+<style lang="scss" scoped>
+// ====== Page ======
+.page {
+  height: 100vh;
+  background: $bg-color-page;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  padding-bottom: constant(safe-area-inset-bottom);
+  padding-bottom: env(safe-area-inset-bottom);
+}
+
+// ====== Search ======
+.search-bar {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  padding: 20rpx 24rpx;
+  background: $bg-color-card;
+  flex-shrink: 0;
+}
+
+.search-box {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  background: $bg-color-page;
+  border-radius: 40rpx;
+  padding: 14rpx 24rpx;
+  height: 68rpx;
+}
+
+.search-icon {
+  position: relative;
+  width: 30rpx;
+  height: 30rpx;
+  margin-right: 14rpx;
+  flex-shrink: 0;
+
+  .si-ring {
+    width: 22rpx; height: 22rpx;
+    border: 3rpx solid $text-color-muted;
+    border-radius: 50%;
+    position: absolute; top: 0; left: 0;
+  }
+  .si-stem {
+    width: 9rpx; height: 3rpx;
+    background: $text-color-muted;
+    border-radius: 2rpx;
+    position: absolute; bottom: 3rpx; right: 2rpx;
+    transform: rotate(45deg);
+  }
+}
+
+.search-input {
+  flex: 1;
+  font-size: 28rpx;
+  color: $text-color-primary;
+}
+
+.search-ph {
+  color: $text-color-muted;
+  font-size: 26rpx;
+}
+
+.search-btn {
+  background: $primary-color;
+  border-radius: 36rpx;
+  padding: 14rpx 32rpx;
+  flex-shrink: 0;
+  transition: opacity 0.15s ease;
+  &:active { opacity: 0.8; }
+
+  .search-btn-text {
+    font-size: 28rpx;
+    color: $text-color-primary;
+    font-weight: 500;
+  }
+}
+
+// ====== Stats ======
+.stats {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 32rpx;
+  background: $bg-color-card;
+  border-bottom: 1rpx solid $border-color-light;
+  flex-shrink: 0;
+}
+
+.stat-item {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4rpx;
+}
+
+.stat-num {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: $text-color-primary;
+  &.active { color: $success-color; }
+  &.disabled { color: $text-color-muted; }
+}
+
+.stat-label {
+  font-size: 22rpx;
+  color: $text-color-muted;
+}
+
+.stat-divider {
+  width: 1rpx;
+  height: 36rpx;
+  background: $border-color-light;
+}
+
+// ====== List ======
+.list-scroll {
+  flex: 1;
+  height: 0;
+}
+
+.card-set {
+  padding: 20rpx 24rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 14rpx;
+}
+
+// ====== Card ======
+.card {
+  display: flex;
+  align-items: center;
+  background: $bg-color-card;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
+  transition: opacity 0.15s ease;
+  gap: 18rpx;
+
+  &:active { opacity: 0.7; }
+}
+
+.card-avatar {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  background: $primary-color-bg;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+
+  text {
+    font-size: 32rpx;
+    font-weight: 700;
+    color: $primary-color-dark;
+  }
+
+  &.off {
+    background: $bg-color-page;
+    text { color: $text-color-placeholder; }
+  }
+}
+
+.card-info {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 8rpx;
+}
+
+.info-top {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+}
+
+.info-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: $text-color-primary;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.info-status {
+  display: flex;
+  align-items: center;
+  gap: 6rpx;
+  flex-shrink: 0;
+
+  text { font-size: 22rpx; font-weight: 500; }
+
+  &.on text { color: $success-color; }
+  &.off text { color: $text-color-muted; }
+}
+
+.info-dot {
+  width: 10rpx; height: 10rpx; border-radius: 50%;
+  &.on { background: $success-color; }
+  &.off { background: $text-color-placeholder; }
+}
+
+.info-sub {
+  display: flex;
+  align-items: center;
+  gap: 6rpx;
+}
+
+.sub-username {
+  font-size: 24rpx;
+  color: $text-color-muted;
+}
+
+.sub-sep {
+  font-size: 22rpx;
+  color: $border-color;
+}
+
+.sub-phone {
+  font-size: 24rpx;
+  color: $text-color-secondary;
+}
+
+.info-role {
+  display: flex;
+  align-items: center;
+}
+
+.role-tag {
+  font-size: 22rpx;
+  color: $accent-color;
+  background: $accent-color-bg;
+  padding: 2rpx 12rpx;
+  border-radius: 6rpx;
+  font-weight: 500;
+}
+
+.card-arrow {
+  width: 36rpx;
+  height: 36rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+
+  .arr-inner {
+    width: 14rpx; height: 14rpx;
+    border-top: 2rpx solid $text-color-placeholder;
+    border-right: 2rpx solid $text-color-placeholder;
+    transform: rotate(45deg);
+  }
+}
+
+// ====== Load more ======
+.load-tip {
+  display: flex;
+  justify-content: center;
+  padding: 36rpx 0 20rpx;
+}
+
+.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;
+  &:nth-child(2) { animation-delay: 0.2s; }
+  &: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 ======
+.end-tip {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 16rpx;
+  padding: 36rpx 0 20rpx;
+}
+.end-line { width: 44rpx; height: 1rpx; background: $border-color; }
+.end-text { font-size: 24rpx; color: $text-color-muted; }
+
+// ====== Empty ======
+.empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 100rpx 0 60rpx;
+  gap: 20rpx;
+}
+
+.empty-icon {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 50%;
+  background: $bg-color-secondary;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.empty-person {
+  position: relative;
+  width: 40rpx;
+  height: 48rpx;
+  .ep-head {
+    position: absolute; top: 0; left: 50%;
+    transform: translateX(-50%);
+    width: 18rpx; height: 18rpx;
+    background: $text-color-placeholder;
+    border-radius: 50%;
+  }
+  .ep-body {
+    position: absolute; bottom: 0; left: 50%;
+    transform: translateX(-50%);
+    width: 34rpx; height: 20rpx;
+    background: $text-color-placeholder;
+    border-radius: 17rpx 17rpx 4rpx 4rpx;
+  }
+}
+
+.empty-title { font-size: 28rpx; color: $text-color-secondary; font-weight: 500; }
+.empty-desc { font-size: 24rpx; color: $text-color-muted; }
+
+// ====== FAB ======
+.fab {
+  position: fixed;
+  right: 40rpx;
+  bottom: 240rpx;
+  width: 96rpx;
+  height: 96rpx;
+  border-radius: 50%;
+  background: $primary-color;
+  box-shadow: 0 8rpx 24rpx rgba(255, 193, 7, 0.35);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 10;
+  transition: opacity 0.15s ease;
+
+  &:active { opacity: 0.8; }
+
+  .fab-text {
+    font-size: 48rpx;
+    color: $text-color-primary;
+    font-weight: 300;
+    line-height: 1;
+    margin-top: -4rpx;
+  }
+}
+</style>

+ 1 - 1
haha-admin-web/src/api/admin.ts

@@ -22,7 +22,7 @@ export const getAdminList = (data: {
   pageSize?: number;
   username?: string;
   phone?: string;
-  roleId?: number;
+  roleIds?: string;
   status?: number;
 }) => {
   return http.request<ResultTable>("post", "/user", { data });

+ 3 - 3
haha-admin-web/src/views/system/admin/index.vue

@@ -62,9 +62,9 @@ const {
           class="w-[180px]!"
         />
       </el-form-item>
-      <el-form-item label="角色:" prop="roleId">
+      <el-form-item label="角色:" prop="roleIds">
         <el-select
-          v-model="form.roleId"
+          v-model="form.roleIds"
           placeholder="请选择角色"
           clearable
           class="w-[180px]!"
@@ -85,7 +85,7 @@ const {
           class="w-[180px]!"
         >
           <el-option label="启用" :value="1" />
-          <el-option label="停用" :value="0" />
+          <el-option label="停用" :value="2" />
         </el-select>
       </el-form-item>
       <el-form-item>

+ 17 - 17
haha-admin-web/src/views/system/admin/utils/hook.tsx

@@ -36,7 +36,7 @@ export function useAdmin(tableRef: Ref) {
   const form = reactive<AdminSearchForm>({
     username: "",
     phone: "",
-    roleId: "",
+    roleIds: "",
     status: ""
   });
   const formRef = ref();
@@ -72,8 +72,8 @@ export function useAdmin(tableRef: Ref) {
       minWidth: 120
     },
     {
-      label: "昵称",
-      prop: "nickname",
+      label: "真实姓名",
+      prop: "realName",
       minWidth: 120
     },
     {
@@ -96,7 +96,7 @@ export function useAdmin(tableRef: Ref) {
           loading={switchLoadMap.value[scope.index]?.loading}
           v-model={scope.row.status}
           active-value={1}
-          inactive-value={0}
+          inactive-value={2}
           active-text="启用"
           inactive-text="停用"
           inline-prompt
@@ -123,7 +123,7 @@ export function useAdmin(tableRef: Ref) {
   // 状态切换
   function onChange({ row, index }) {
     ElMessageBox.confirm(
-      `确认要<strong>${row.status === 0 ? "停用" : "启用"}</strong>管理员<strong style='color:var(--el-color-primary)'>${row.username}</strong>吗?`,
+      `确认要<strong>${row.status === 2 ? "停用" : "启用"}</strong>管理员<strong style='color:var(--el-color-primary)'>${row.username}</strong>吗?`,
       "系统提示",
       {
         confirmButtonText: "确定",
@@ -141,13 +141,13 @@ export function useAdmin(tableRef: Ref) {
           switchLoadMap.value[index] = Object.assign({}, switchLoadMap.value[index], {
             loading: false
           });
-          message(`已${row.status === 0 ? "停用" : "启用"}管理员 ${row.username}`, {
+          message(`已${row.status === 2 ? "停用" : "启用"}管理员 ${row.username}`, {
             type: "success"
           });
         }, 300);
       })
       .catch(() => {
-        row.status = row.status === 0 ? 1 : 0;
+        row.status = row.status === 2 ? 1 : 2;
       });
   }
 
@@ -210,7 +210,7 @@ export function useAdmin(tableRef: Ref) {
       // 只添加非空的搜索条件
       if (form.username) searchParams.username = form.username;
       if (form.phone) searchParams.phone = form.phone;
-      if (form.roleId) searchParams.roleId = form.roleId;
+      if (form.roleIds) searchParams.roleIds = form.roleIds;
       if (form.status) searchParams.status = form.status;
       
       const { data } = await getAdminList(searchParams);
@@ -244,10 +244,10 @@ export function useAdmin(tableRef: Ref) {
           id: row?.id,
           username: row?.username ?? "",
           password: "",
-          nickname: row?.nickname ?? "",
+          realName: row?.realName ?? "",
           phone: row?.phone ?? "",
           email: row?.email ?? "",
-          roleId: row?.roleId ?? "",
+          roleIds: row?.roleIds ?? "",
           status: row?.status ?? 1,
           remark: row?.remark ?? ""
         }
@@ -287,11 +287,11 @@ export function useAdmin(tableRef: Ref) {
             </ElFormItem>
           )}
           <ElFormItem
-            label="昵称"
-            prop="nickname"
-            rules={[{ required: true, message: "请输入昵称", trigger: "blur" }]}
+            label="真实姓名"
+            prop="realName"
+            rules={[{ required: true, message: "请输入真实姓名", trigger: "blur" }]}
           >
-            <ElInput v-model={form.nickname} placeholder="请输入昵称" clearable />
+            <ElInput v-model={form.realName} placeholder="请输入真实姓名" clearable />
           </ElFormItem>
           <ElFormItem
             label="手机号"
@@ -308,10 +308,10 @@ export function useAdmin(tableRef: Ref) {
           </ElFormItem>
           <ElFormItem
             label="角色"
-            prop="roleId"
+            prop="roleIds"
             rules={[{ required: true, message: "请选择角色", trigger: "change" }]}
           >
-            <ElSelect v-model={form.roleId} placeholder="请选择角色" class="w-full">
+            <ElSelect v-model={form.roleIds} placeholder="请选择角色" class="w-full">
               {roleOptions.value.map(item => (
                 <ElOption key={item.id} label={item.name} value={item.id} />
               ))}
@@ -320,7 +320,7 @@ export function useAdmin(tableRef: Ref) {
           <ElFormItem label="状态" prop="status">
             <ElRadioGroup v-model={form.status}>
               <ElRadioButton value={1}>启用</ElRadioButton>
-              <ElRadioButton value={0}>停用</ElRadioButton>
+              <ElRadioButton value={2}>停用</ElRadioButton>
             </ElRadioGroup>
           </ElFormItem>
           <ElFormItem label="备注" prop="remark">

+ 3 - 3
haha-admin-web/src/views/system/admin/utils/types.ts

@@ -3,11 +3,11 @@ export interface AdminFormItem {
   id?: number;
   username: string;
   password?: string;
-  nickname: string;
+  realName: string;
   phone: string;
   email?: string;
   avatar?: string;
-  roleId: number;
+  roleIds: string;
   roleName?: string;
   status: number;
   remark?: string;
@@ -24,7 +24,7 @@ export interface AdminFormProps {
 export interface AdminSearchForm {
   username: string;
   phone: string;
-  roleId: number | string;
+  roleIds: string;
   status: number | string;
 }
 

+ 53 - 24
haha-admin-web/src/views/system/announcement/index.vue

@@ -5,6 +5,7 @@ import { PureTableBar } from "@/components/RePureTableBar";
 import { useRenderIcon } from "@/components/ReIcon/src/hooks";
 
 import Delete from "~icons/ep/delete";
+import Edit from "~icons/ep/edit";
 import Refresh from "~icons/ep/refresh";
 import AddFill from "~icons/ri/add-circle-line";
 import Top from "~icons/ep/top";
@@ -82,7 +83,7 @@ const {
           type="primary"
           :icon="useRenderIcon('ri/search-line')"
           :loading="loading"
-          @click="onSearch"
+          @click="onSearch(true)"
         >
           搜索
         </el-button>
@@ -128,37 +129,65 @@ const {
         >
           <template #operation="{ row }">
             <el-button
-              v-if="row.status === 0"
               class="reset-margin"
               link
-              type="success"
+              type="primary"
               :size="size"
-              :icon="useRenderIcon(VideoPlay)"
-              @click="handlePublish(row)"
+              :icon="useRenderIcon(Edit)"
+              @click="openDialog('编辑', row)"
             >
-              发布
+              编辑
             </el-button>
-            <el-button
+            <el-popconfirm
+              v-if="row.status === 0"
+              title="确认发布该公告?"
+              @confirm="handlePublish(row)"
+            >
+              <template #reference>
+                <el-button
+                  class="reset-margin"
+                  link
+                  type="success"
+                  :size="size"
+                  :icon="useRenderIcon(VideoPlay)"
+                >
+                  发布
+                </el-button>
+              </template>
+            </el-popconfirm>
+            <el-popconfirm
               v-if="row.status === 1"
-              class="reset-margin"
-              link
-              type="warning"
-              :size="size"
-              :icon="useRenderIcon(VideoPause)"
-              @click="handleOffline(row)"
+              title="确认下线该公告?"
+              @confirm="handleOffline(row)"
             >
-              下线
-            </el-button>
-            <el-button
-              class="reset-margin"
-              link
-              :type="row.isTop === 1 ? 'danger' : 'primary'"
-              :size="size"
-              :icon="useRenderIcon(Top)"
-              @click="handleSetTop(row)"
+              <template #reference>
+                <el-button
+                  class="reset-margin"
+                  link
+                  type="warning"
+                  :size="size"
+                  :icon="useRenderIcon(VideoPause)"
+                >
+                  下线
+                </el-button>
+              </template>
+            </el-popconfirm>
+            <el-popconfirm
+              :title="row.isTop === 1 ? '确认取消置顶?' : '确认置顶该公告?'"
+              @confirm="handleSetTop(row)"
             >
-              {{ row.isTop === 1 ? '取消置顶' : '置顶' }}
-            </el-button>
+              <template #reference>
+                <el-button
+                  class="reset-margin"
+                  link
+                  :type="row.isTop === 1 ? 'danger' : 'primary'"
+                  :size="size"
+                  :icon="useRenderIcon(Top)"
+                >
+                  {{ row.isTop === 1 ? '取消置顶' : '置顶' }}
+                </el-button>
+              </template>
+            </el-popconfirm>
             <el-popconfirm
               title="是否确认删除?"
               @confirm="handleDelete(row)"

+ 42 - 23
haha-admin-web/src/views/system/announcement/utils/hook.tsx

@@ -105,13 +105,14 @@ export function useAnnouncement() {
     {
       label: "操作",
       fixed: "right",
-      width: 280,
+      width: 340,
       slot: "operation"
     }
   ];
 
-  async function onSearch() {
+  async function onSearch(resetPage = false) {
     loading.value = true;
+    if (resetPage) pagination.currentPage = 1;
     try {
       const searchParams: any = {
         page: pagination.currentPage,
@@ -141,40 +142,49 @@ export function useAnnouncement() {
   }
 
   async function handleDelete(row) {
-    const { code } = await deleteAnnouncement(row.id);
-    if (code === 0) {
+    const { code, message: msg } = await deleteAnnouncement(row.id);
+    if (code === 200) {
       message("删除成功", { type: "success" });
       onSearch();
+    } else {
+      message(msg || "删除失败", { type: "error" });
     }
   }
 
   async function handlePublish(row) {
-    const { code } = await publishAnnouncement(row.id);
-    if (code === 0) {
+    const { code, message: msg } = await publishAnnouncement(row.id);
+    if (code === 200) {
       message("发布成功", { type: "success" });
       onSearch();
+    } else {
+      message(msg || "发布失败", { type: "error" });
     }
   }
 
   async function handleOffline(row) {
-    const { code } = await offlineAnnouncement(row.id);
-    if (code === 0) {
+    const { code, message: msg } = await offlineAnnouncement(row.id);
+    if (code === 200) {
       message("下线成功", { type: "success" });
       onSearch();
+    } else {
+      message(msg || "下线失败", { type: "error" });
     }
   }
 
   async function handleSetTop(row) {
     const newIsTop = row.isTop === 1 ? 0 : 1;
-    const { code } = await setAnnouncementTop(row.id, newIsTop);
-    if (code === 0) {
+    const { code, message: msg } = await setAnnouncementTop(row.id, newIsTop);
+    if (code === 200) {
       message(newIsTop === 1 ? "置顶成功" : "取消置顶成功", { type: "success" });
       onSearch();
+    } else {
+      message(msg || "操作失败", { type: "error" });
     }
   }
 
   function handleSizeChange(val: number) {
     pagination.pageSize = val;
+    pagination.currentPage = 1;
     onSearch();
   }
 
@@ -206,9 +216,9 @@ export function useAnnouncement() {
       fullscreen: deviceDetection(),
       contentRenderer: () => (
         <ElForm
-          ref={ruleFormRef}
+          ref={(el: any) => { ruleFormRef.value = el; }}
           model={formInline}
-          label-width="100px"
+          labelWidth="100px"
         >
           <ElFormItem
             label="公告标题"
@@ -246,34 +256,43 @@ export function useAnnouncement() {
           </ElFormItem>
           <ElFormItem label="状态" prop="status">
             <ElRadioGroup v-model={formInline.status}>
-              <ElRadioButton value={0}>草稿</ElRadioButton>
-              <ElRadioButton value={1}>立即发布</ElRadioButton>
+              <ElRadioButton label={0}>草稿</ElRadioButton>
+              <ElRadioButton label={1}>立即发布</ElRadioButton>
             </ElRadioGroup>
           </ElFormItem>
         </ElForm>
       ),
       beforeSure: async (done, { options }) => {
-        const formRef = options.props.ruleFormRef;
-        if (!formRef.value) return;
+        if (!ruleFormRef.value) {
+          message("表单未初始化,请关闭弹窗重试", { type: "error" });
+          return;
+        }
 
         try {
-          const valid = await formRef.value.validate().catch(() => false);
-          if (!valid) return;
+          const valid = await ruleFormRef.value.validate().catch(() => false);
+          if (!valid) {
+            message("请完善必填信息", { type: "warning" });
+            return;
+          }
 
-          const formData = toRaw(options.props.formInline);
+          const formData = toRaw(formInline);
           if (formData.id) {
-            const { code } = await updateAnnouncement(formData.id, formData);
-            if (code === 0) {
+            const { code, message: msg } = await updateAnnouncement(formData.id, formData);
+            if (code === 200) {
               message(`修改公告 ${formData.title} 成功`, { type: "success" });
               done();
               onSearch();
+            } else {
+              message(msg || "修改失败", { type: "error" });
             }
           } else {
-            const { code } = await createAnnouncement(formData);
-            if (code === 0) {
+            const { code, message: msg } = await createAnnouncement(formData);
+            if (code === 200) {
               message(`新增公告 ${formData.title} 成功`, { type: "success" });
               done();
               onSearch();
+            } else {
+              message(msg || "新增失败", { type: "error" });
             }
           }
         } catch (error) {

+ 16 - 3
haha-admin/src/main/java/com/haha/admin/controller/AnnouncementController.java

@@ -64,12 +64,24 @@ public class AnnouncementController {
     @PostMapping
     public Result<Announcement> create(@RequestBody Announcement announcement) {
         try {
-            announcement.setStatus(AnnouncementConstants.STATUS_DRAFT); // 默认草稿状态
-            announcement.setIsTop(AnnouncementConstants.NOT_TOP);  // 默认不置顶
+            announcement.setIsTop(AnnouncementConstants.NOT_TOP);
             announcement.setReadCount(0);
             announcement.setCreateTime(LocalDateTime.now());
             announcement.setUpdateTime(LocalDateTime.now());
 
+            if (announcement.getStatus() != null
+                    && announcement.getStatus() == AnnouncementConstants.STATUS_PUBLISHED) {
+                Long userId = StpUtil.getLoginIdAsLong();
+                Object userNameObj = StpUtil.getSession().get("username");
+                String userName = userNameObj != null ? userNameObj.toString() : userId.toString();
+                announcement.setStatus(AnnouncementConstants.STATUS_PUBLISHED);
+                announcement.setPublishTime(LocalDateTime.now());
+                announcement.setPublisherId(userId);
+                announcement.setPublisherName(userName);
+            } else {
+                announcement.setStatus(AnnouncementConstants.STATUS_DRAFT);
+            }
+
             announcementService.save(announcement);
             return Result.success("创建成功", announcement);
         } catch (Exception e) {
@@ -136,7 +148,8 @@ public class AnnouncementController {
     public Result<String> publish(@PathVariable Long id) {
         try {
             Long userId = StpUtil.getLoginIdAsLong();
-            String userName = StpUtil.getLoginIdAsString();
+            Object userNameObj = StpUtil.getSession().get("username");
+            String userName = userNameObj != null ? userNameObj.toString() : userId.toString();
 
             boolean success = announcementService.publish(id, userId, userName);
             if (success) {