Commit 19415acca8eeec4163b219f2813a5103537ac188

Authored by “wangming”
1 parent 7824135f

Enhance Form.vue with location search functionality and improved user experience…

…. Added address input for store location retrieval, integrated Tencent Maps API for geolocation, and updated form fields to include longitude, latitude, and fence polygons. Adjusted dialog and layout for better usability.
antis-ncc-admin/src/views/lqMdxx/Form.vue
... ... @@ -107,11 +107,23 @@
107 107 <el-dialog title="门店地图定位" :visible.sync="locationVisible" width="800px" append-to-body class="map-dialog">
108 108 <div class="map-container-wrapper">
109 109 <div class="map-header-bar">
110   - <span>在地图上点击以选择门店位置(仅支持单个标记)</span>
  110 + <span>在地图上点击以选择门店位置,或通过地址检索定位</span>
111 111 <div class="coordinate-info" v-if="tempMarker.lng">
112 112 当前选择:{{ tempMarker.lng.toFixed(6) }}, {{ tempMarker.lat.toFixed(6) }}
113 113 </div>
114 114 </div>
  115 + <div class="location-search-bar">
  116 + <el-input
  117 + v-model="locationSearchKeyword"
  118 + placeholder="输入地址或关键词检索(如:成都市武侯区紫荆北路182号)"
  119 + clearable
  120 + size="small"
  121 + class="search-input"
  122 + @keyup.enter.native="handleSearchLocation"
  123 + >
  124 + <el-button slot="append" icon="el-icon-search" @click="handleSearchLocation" :loading="locationSearchLoading">检索</el-button>
  125 + </el-input>
  126 + </div>
115 127 <div id="location-map" class="map-canvas"></div>
116 128 </div>
117 129 <span slot="footer" class="dialog-footer">
... ... @@ -163,6 +175,29 @@
163 175 <script>
164 176 import request from '@/utils/request'
165 177  
  178 +// 腾讯地图 WebService API Key(与地图脚本共用,需在腾讯控制台开启 WebServiceAPI)
  179 +const TMAP_WS_KEY = 'YRXBZ-NEV6T-K7SXH-VJPMF-G5IQF-F3FCJ'
  180 +
  181 +// JSONP 调用(绕过 CORS,腾讯 WebService API 前端需用 JSONP)
  182 +function jsonp(url) {
  183 + return new Promise((resolve, reject) => {
  184 + const cbName = '_tmap_jsonp_' + Date.now() + '_' + Math.random().toString(36).slice(2);
  185 + const script = document.createElement('script');
  186 + script.src = url + (url.indexOf('?') >= 0 ? '&' : '?') + 'output=jsonp&callback=' + cbName;
  187 + window[cbName] = function(res) {
  188 + if (script.parentNode) script.parentNode.removeChild(script);
  189 + delete window[cbName];
  190 + resolve(res);
  191 + };
  192 + script.onerror = function() {
  193 + if (script.parentNode) script.parentNode.removeChild(script);
  194 + delete window[cbName];
  195 + reject(new Error('JSONP request failed'));
  196 + };
  197 + document.body.appendChild(script);
  198 + });
  199 +}
  200 +
166 201 export default {
167 202 data() {
168 203 return {
... ... @@ -198,6 +233,8 @@ export default {
198 233 locationMap: null,
199 234 locationMarkerLayer: null,
200 235 tempMarker: { lng: null, lat: null },
  236 + locationSearchKeyword: '',
  237 + locationSearchLoading: false,
201 238  
202 239 // 围栏弹窗相关
203 240 fenceVisible: false,
... ... @@ -237,8 +274,34 @@ export default {
237 274 if (this.dataForm.id) {
238 275 request({ url: '/api/Extend/LqMdxx/' + this.dataForm.id, method: 'get' })
239 276 .then(res => {
240   - this.dataForm = res.data;
  277 + const raw = res.data || {};
  278 + // 合并接口数据,保留 dataForm 默认结构
  279 + this.dataForm = {
  280 + ...this.dataForm,
  281 + ...raw,
  282 + // 经纬度:兼容后端 camelCase / PascalCase(避免 ?? 语法,兼容旧 Babel)
  283 + longitude: raw.longitude != null ? raw.longitude : (raw.Longitude != null ? raw.Longitude : this.dataForm.longitude),
  284 + latitude: raw.latitude != null ? raw.latitude : (raw.Latitude != null ? raw.Latitude : this.dataForm.latitude)
  285 + };
  286 + // fencePolygons:后端返回 JSON 字符串需解析为数组,兼容 fence_polygons 字段名
  287 + const fpRaw = raw.fencePolygons != null ? raw.fencePolygons : (raw.fence_polygons != null ? raw.fence_polygons : raw.FencePolygons);
  288 + if (typeof fpRaw === 'string') {
  289 + try {
  290 + this.dataForm.fencePolygons = JSON.parse(fpRaw) || [];
  291 + } catch (e) {
  292 + this.dataForm.fencePolygons = [];
  293 + }
  294 + } else if (Array.isArray(fpRaw)) {
  295 + this.dataForm.fencePolygons = fpRaw;
  296 + } else {
  297 + this.dataForm.fencePolygons = Array.isArray(this.dataForm.fencePolygons) ? this.dataForm.fencePolygons : [];
  298 + }
241 299 });
  300 + } else {
  301 + // 新建时重置经纬度与围栏
  302 + this.dataForm.longitude = null;
  303 + this.dataForm.latitude = null;
  304 + this.dataForm.fencePolygons = [];
242 305 }
243 306 })
244 307 },
... ... @@ -257,12 +320,111 @@ export default {
257 320 // --- 门店定位逻辑 ---
258 321 handleOpenLocation() {
259 322 this.locationVisible = true;
260   - this.tempMarker = { lng: this.dataForm.longitude, lat: this.dataForm.latitude };
  323 + // 若已有经纬度,则以门店经纬度为中心;否则先不设置,等 IP 定位
  324 + this.tempMarker = {
  325 + lng: this.dataForm.longitude,
  326 + lat: this.dataForm.latitude
  327 + };
  328 + // 仅作为默认检索关键字,不自动发起检索
  329 + this.locationSearchKeyword = this.dataForm.dz || '';
  330 +
261 331 this.$nextTick(() => {
262   - this.initLocationMap();
  332 + // 如果已有经纬度,直接按门店坐标初始化地图
  333 + if (this.dataForm.longitude && this.dataForm.latitude) {
  334 + this.initLocationMap();
  335 + } else {
  336 + // 无经纬度时,优先用 IP 做一个大概定位,再初始化地图
  337 + this.initLocationWithIp();
  338 + }
263 339 });
264 340 },
265 341  
  342 + // 使用腾讯地图 IP 定位作为初始中心(仅在没有门店经纬度时调用)
  343 + initLocationWithIp() {
  344 + const ipUrl = 'https://apis.map.qq.com/ws/location/v1/ip?key=' + TMAP_WS_KEY;
  345 + // 先尝试 IP 定位(JSONP),成功后设置 tempMarker 再初始化地图;失败则走默认中心
  346 + jsonp(ipUrl)
  347 + .then((res) => {
  348 + if (res.status === 0 && res.result && res.result.location) {
  349 + const loc = res.result.location;
  350 + this.tempMarker = { lng: loc.lng, lat: loc.lat };
  351 + // 同步一下城市,便于后续检索时 region 使用
  352 + if (res.result.ad_info && res.result.ad_info.city && !this.dataForm.cs) {
  353 + this.dataForm.cs = res.result.ad_info.city;
  354 + }
  355 + }
  356 + this.initLocationMap();
  357 + })
  358 + .catch(() => {
  359 + this.initLocationMap();
  360 + });
  361 + },
  362 +
  363 + // 地址检索:使用腾讯地图 WebService API(地址解析 + 地点搜索)
  364 + handleSearchLocation() {
  365 + const keyword = (this.locationSearchKeyword || '').trim();
  366 + if (!keyword) {
  367 + this.$message.warning('请输入地址或关键词');
  368 + return;
  369 + }
  370 + this.locationSearchLoading = true;
  371 + const region = this.dataForm.cs || '';
  372 +
  373 + // 1. 先调用地址解析 API(适合完整地址),使用 JSONP 绕过 CORS
  374 + const geocoderUrl = 'https://apis.map.qq.com/ws/geocoder/v1/?address=' + encodeURIComponent(keyword) + '&key=' + TMAP_WS_KEY + (region ? '&region=' + encodeURIComponent(region) : '');
  375 + jsonp(geocoderUrl)
  376 + .then((res) => {
  377 + if (res.status === 0 && res.result && res.result.location) {
  378 + const loc = res.result.location;
  379 + this.applySearchResult(loc.lat, loc.lng);
  380 + this.locationSearchLoading = false;
  381 + this.$message.success('检索成功');
  382 + } else {
  383 + this.searchByKeyword(keyword, region);
  384 + }
  385 + })
  386 + .catch(() => {
  387 + this.searchByKeyword(keyword, region);
  388 + });
  389 + },
  390 +
  391 + // 关键词搜索:使用腾讯地图地点搜索 WebService API
  392 + searchByKeyword(keyword, region) {
  393 + const cityName = region || '全国';
  394 + const boundary = 'region(' + cityName + ',1)';
  395 + const searchUrl = 'https://apis.map.qq.com/ws/place/v1/search?keyword=' + encodeURIComponent(keyword) + '&boundary=' + encodeURIComponent(boundary) + '&page_size=10&key=' + TMAP_WS_KEY;
  396 + jsonp(searchUrl)
  397 + .then((res) => {
  398 + this.locationSearchLoading = false;
  399 + if (res.status === 0 && res.data && res.data.length > 0) {
  400 + const first = res.data[0];
  401 + const loc = first.location;
  402 + this.applySearchResult(loc.lat, loc.lng);
  403 + this.$message.success('检索成功');
  404 + } else {
  405 + this.$message.warning('未找到匹配结果,请尝试其他关键词或在地图上直接点击选择');
  406 + }
  407 + })
  408 + .catch(() => {
  409 + this.locationSearchLoading = false;
  410 + this.$message.error('检索失败,请稍后再试');
  411 + });
  412 + },
  413 +
  414 + // 将检索结果应用到地图(移动中心、设置标记)
  415 + applySearchResult(lat, lng) {
  416 + this.tempMarker = { lng: lng, lat: lat };
  417 + if (window.TMap && this.locationMarkerLayer) {
  418 + const TMap = window.TMap;
  419 + this.locationMarkerLayer.setGeometries([{ id: 'm', position: new TMap.LatLng(lat, lng) }]);
  420 + }
  421 + if (window.TMap && this.locationMap) {
  422 + const TMap = window.TMap;
  423 + this.locationMap.setCenter(new TMap.LatLng(lat, lng));
  424 + this.locationMap.setZoom(16);
  425 + }
  426 + },
  427 +
266 428 initLocationMap() {
267 429 this.loadTMapScript().then(() => {
268 430 const TMap = window.TMap;
... ... @@ -515,10 +677,15 @@ export default {
515 677 this.$refs['elForm'].validate((valid) => {
516 678 if (!valid) return;
517 679 const isNew = !this.dataForm.id;
  680 + // 提交前:fencePolygons 若为数组则序列化为 JSON 字符串,与后端约定一致
  681 + const submitData = { ...this.dataForm };
  682 + if (Array.isArray(submitData.fencePolygons)) {
  683 + submitData.fencePolygons = JSON.stringify(submitData.fencePolygons);
  684 + }
518 685 request({
519 686 url: isNew ? '/api/Extend/LqMdxx' : `/api/Extend/LqMdxx/${this.dataForm.id}`,
520 687 method: isNew ? 'POST' : 'PUT',
521   - data: this.dataForm
  688 + data: submitData
522 689 }).then((res) => {
523 690 this.$message({
524 691 message: res.msg,
... ... @@ -568,6 +735,14 @@ export default {
568 735 font-weight: bold;
569 736 }
570 737 }
  738 +
  739 + .location-search-bar {
  740 + margin-bottom: 10px;
  741 +
  742 + .search-input {
  743 + width: 100%;
  744 + }
  745 + }
571 746 }
572 747  
573 748 .map-canvas {
... ...
docs/数据库说明.md
... ... @@ -53,6 +53,9 @@
53 53  
54 54 ### 1. 门店与人员关系
55 55 - **门店信息表**: `lq_mdxx` (门店基础信息)
  56 + - `longitude` (DECIMAL): 经度
  57 + - `latitude` (DECIMAL): 纬度
  58 + - `fence_polygons` (JSON): 电子围栏多边形坐标,格式 `[[{lng,lat},...]]`
56 59 - **人员信息**: `BASE_USER` (系统用户表,包含门店ID等扩展字段)
57 60 - **关联字段**: `BASE_USER.F_MDID` ↔ `lq_mdxx.F_Id`
58 61  
... ...
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqMdxx/LqMdxxCrInput.cs
1   -using System;
  1 +using System;
2 2 using System.Collections.Generic;
3 3  
4 4 namespace NCC.Extend.Entitys.Dto.LqMdxx
... ... @@ -44,6 +44,21 @@ namespace NCC.Extend.Entitys.Dto.LqMdxx
44 44 public string dz { get; set; }
45 45  
46 46 /// <summary>
  47 + /// 经度
  48 + /// </summary>
  49 + public decimal? longitude { get; set; }
  50 +
  51 + /// <summary>
  52 + /// 纬度
  53 + /// </summary>
  54 + public decimal? latitude { get; set; }
  55 +
  56 + /// <summary>
  57 + /// 电子围栏多边形坐标,JSON 字符串,格式:[[{lng,lat},...]]
  58 + /// </summary>
  59 + public string fencePolygons { get; set; }
  60 +
  61 + /// <summary>
47 62 /// 姓名
48 63 /// </summary>
49 64 public string xm { get; set; }
... ...
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqMdxx/LqMdxxInfoOutput.cs
1   -using System;
  1 +using System;
2 2 using System.Collections.Generic;
3 3 using NCC.Code;
4 4 using NCC.Extend.Entitys.Enum;
... ... @@ -46,6 +46,21 @@ namespace NCC.Extend.Entitys.Dto.LqMdxx
46 46 public string dz { get; set; }
47 47  
48 48 /// <summary>
  49 + /// 经度
  50 + /// </summary>
  51 + public decimal? longitude { get; set; }
  52 +
  53 + /// <summary>
  54 + /// 纬度
  55 + /// </summary>
  56 + public decimal? latitude { get; set; }
  57 +
  58 + /// <summary>
  59 + /// 电子围栏多边形坐标,JSON 字符串,格式:[[{lng,lat},...]]
  60 + /// </summary>
  61 + public string fencePolygons { get; set; }
  62 +
  63 + /// <summary>
49 64 /// 姓名
50 65 /// </summary>
51 66 public string xm { get; set; }
... ...
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/lq_mdxx/LqMdxxEntity.cs
1   -using NCC.Common.Const;
  1 +using NCC.Common.Const;
2 2 using SqlSugar;
3 3 using System;
4 4  
... ... @@ -54,6 +54,24 @@ namespace NCC.Extend.Entitys.lq_mdxx
54 54 public string Dz { get; set; }
55 55  
56 56 /// <summary>
  57 + /// 经度
  58 + /// </summary>
  59 + [SugarColumn(ColumnName = "longitude")]
  60 + public decimal? Longitude { get; set; }
  61 +
  62 + /// <summary>
  63 + /// 纬度
  64 + /// </summary>
  65 + [SugarColumn(ColumnName = "latitude")]
  66 + public decimal? Latitude { get; set; }
  67 +
  68 + /// <summary>
  69 + /// 电子围栏多边形坐标,JSON 格式:[[{lng,lat},...]]
  70 + /// </summary>
  71 + [SugarColumn(ColumnName = "fence_polygons")]
  72 + public string FencePolygons { get; set; }
  73 +
  74 + /// <summary>
57 75 /// 姓名
58 76 /// </summary>
59 77 [SugarColumn(ColumnName = "xm")]
... ...
sql/添加门店经纬度与电子围栏字段.sql 0 → 100644
  1 +-- 门店信息表添加经纬度与电子围栏字段
  2 +-- 用于门店编辑页面的地图定位与电子围栏功能
  3 +
  4 +ALTER TABLE lq_mdxx
  5 + ADD COLUMN longitude DECIMAL(10, 6) NULL COMMENT '经度' AFTER dz,
  6 + ADD COLUMN latitude DECIMAL(10, 6) NULL COMMENT '纬度' AFTER longitude,
  7 + ADD COLUMN fence_polygons JSON NULL COMMENT '电子围栏多边形坐标,格式:[[{lng,lat},...]]' AFTER latitude;
... ...
store-pc/src/components/CompanyCalendarDialog.vue 0 → 100644
  1 +<template>
  2 + <el-dialog
  3 + :visible.sync="visibleProxy"
  4 + :show-close="false"
  5 + width="90%"
  6 + :close-on-click-modal="false"
  7 + custom-class="company-calendar-dialog"
  8 + append-to-body
  9 + >
  10 + <div class="dialog-inner">
  11 + <div class="dialog-header">
  12 + <div class="dialog-title">公司日历</div>
  13 + <div class="dialog-legend">
  14 + <span v-for="item in legendList" :key="item.type" class="legend-item">
  15 + <span class="legend-dot" :style="{ background: item.color }"></span>
  16 + {{ item.label }}
  17 + </span>
  18 + </div>
  19 + <span class="dialog-close" @click="visibleProxy = false"><i class="el-icon-close"></i></span>
  20 + </div>
  21 +
  22 + <div class="dialog-content" v-loading="loading">
  23 + <FullCalendar
  24 + ref="fullCalendar"
  25 + class="company-calendar"
  26 + defaultView="dayGridMonth"
  27 + :header="calendarHeader"
  28 + :plugins="calendarPlugins"
  29 + :weekends="true"
  30 + :events="calendarEvents"
  31 + locale="zh-cn"
  32 + :buttonText="buttonText"
  33 + :height="calendarHeight"
  34 + :eventLimit="true"
  35 + allDayText="全天"
  36 + :editable="false"
  37 + @datesRender="datesRender"
  38 + @eventClick="handleEventClick"
  39 + />
  40 + </div>
  41 + </div>
  42 + </el-dialog>
  43 +</template>
  44 +
  45 +<script>
  46 +import FullCalendar from '@fullcalendar/vue'
  47 +import dayGridPlugin from '@fullcalendar/daygrid'
  48 +import timeGridPlugin from '@fullcalendar/timegrid'
  49 +import interactionPlugin from '@fullcalendar/interaction'
  50 +
  51 +export default {
  52 + name: 'CompanyCalendarDialog',
  53 + components: { FullCalendar },
  54 + props: {
  55 + visible: { type: Boolean, default: false }
  56 + },
  57 + data() {
  58 + const today = new Date()
  59 + const y = today.getFullYear()
  60 + const m = String(today.getMonth() + 1).padStart(2, '0')
  61 + const d = String(today.getDate()).padStart(2, '0')
  62 + const todayStr = `${y}-${m}-${d}`
  63 + return {
  64 + loading: false,
  65 + // 公司层面的会议/活动数据(后续可换成接口)
  66 + companyEvents: [
  67 + // 当天 7 条示例(操作会 + 大会 + 活动),方便预览密集情况
  68 + { id: 'E001', date: todayStr, start: `${todayStr}T09:00:00`, end: `${todayStr}T10:00:00`, type: 'ops', title: '操作会 - 紫荆店' },
  69 + { id: 'E002', date: todayStr, start: `${todayStr}T10:30:00`, end: `${todayStr}T11:30:00`, type: 'ops', title: '操作会 - 西沙店' },
  70 + { id: 'E003', date: todayStr, start: `${todayStr}T13:00:00`, end: `${todayStr}T14:00:00`, type: 'training', title: '培训会 - 新员工入职营' },
  71 + { id: 'E004', date: todayStr, start: `${todayStr}T14:30:00`, end: `${todayStr}T15:30:00`, type: 'training', title: '培训会 - 科美新品讲解' },
  72 + { id: 'E005', date: todayStr, start: `${todayStr}T16:00:00`, end: `${todayStr}T17:00:00`, type: 'activity', title: '门店活动 - 紫荆店会员沙龙' },
  73 + { id: 'E006', date: todayStr, start: `${todayStr}T18:00:00`, end: `${todayStr}T19:00:00`, type: 'activity', title: '门店活动 - 西沙店体验日' },
  74 + { id: 'E007', date: todayStr, start: `${todayStr}T19:30:00`, end: `${todayStr}T21:00:00`, type: 'meeting', title: '全体大会 - 本月复盘' },
  75 + // 其它日期:尽量覆盖当月大部分天
  76 + { id: 'E008', date: `${y}-${m}-01`, start: `${y}-${m}-01T10:00:00`, end: `${y}-${m}-01T12:00:00`, type: 'meeting', title: '月初经营例会' },
  77 + { id: 'E009', date: `${y}-${m}-02`, start: `${y}-${m}-02T14:00:00`, end: `${y}-${m}-02T16:00:00`, type: 'ops', title: '操作会 - 静居寺店' },
  78 + { id: 'E010', date: `${y}-${m}-03`, start: `${y}-${m}-03T09:30:00`, end: `${y}-${m}-03T11:00:00`, type: 'training', title: '培训会 - 会员沟通话术' },
  79 + { id: 'E011', date: `${y}-${m}-04`, start: `${y}-${m}-04T15:00:00`, end: `${y}-${m}-04T17:00:00`, type: 'activity', title: '门店活动 - 双流店开放日' },
  80 + { id: 'E012', date: `${y}-${m}-05`, start: `${y}-${m}-05T10:00:00`, end: `${y}-${m}-05T11:30:00`, type: 'ops', title: '操作会 - 保利店' },
  81 + { id: 'E013', date: `${y}-${m}-06`, start: `${y}-${m}-06T14:00:00`, end: `${y}-${m}-06T15:30:00`, type: 'training', title: '培训会 - 逆龄项目进阶' },
  82 + { id: 'E014', date: `${y}-${m}-07`, start: `${y}-${m}-07T19:00:00`, end: `${y}-${m}-07T20:30:00`, type: 'activity', title: '门店活动 - 线上直播专场' },
  83 + { id: 'E015', date: `${y}-${m}-08`, start: `${y}-${m}-08T09:30:00`, end: `${y}-${m}-08T11:00:00`, type: 'meeting', title: '事业部经营会' },
  84 + { id: 'E016', date: `${y}-${m}-09`, start: `${y}-${m}-09T13:30:00`, end: `${y}-${m}-09T15:00:00`, type: 'ops', title: '操作会 - 南湖店' },
  85 + { id: 'E017', date: `${y}-${m}-10`, start: `${y}-${m}-10T10:00:00`, end: `${y}-${m}-10T11:30:00`, type: 'training', title: '培训会 - 顾问心法' },
  86 + { id: 'E018', date: `${y}-${m}-11`, start: `${y}-${m}-11T15:00:00`, end: `${y}-${m}-11T17:00:00`, type: 'activity', title: '门店活动 - 西站店沙龙' },
  87 + { id: 'E019', date: `${y}-${m}-12`, start: `${y}-${m}-12T09:30:00`, end: `${y}-${m}-12T11:00:00`, type: 'ops', title: '操作会 - 融创店' },
  88 + { id: 'E020', date: `${y}-${m}-13`, start: `${y}-${m}-13T14:00:00`, end: `${y}-${m}-13T15:30:00`, type: 'training', title: '培训会 - 经典案例分享' },
  89 + { id: 'E021', date: `${y}-${m}-14`, start: `${y}-${m}-14T19:00:00`, end: `${y}-${m}-14T20:30:00`, type: 'activity', title: '门店活动 - 会员观影会' },
  90 + { id: 'E022', date: `${y}-${m}-15`, start: `${y}-${m}-15T10:00:00`, end: `${y}-${m}-15T12:00:00`, type: 'training', title: '产品培训 - 生命之波' },
  91 + { id: 'E023', date: `${y}-${m}-16`, start: `${y}-${m}-16T09:30:00`, end: `${y}-${m}-16T11:00:00`, type: 'ops', title: '操作会 - 科技部' },
  92 + { id: 'E024', date: `${y}-${m}-17`, start: `${y}-${m}-17T15:00:00`, end: `${y}-${m}-17T17:00:00`, type: 'activity', title: '门店活动 - 会员答谢宴' },
  93 + { id: 'E025', date: `${y}-${m}-18`, start: `${y}-${m}-18T14:00:00`, end: `${y}-${m}-18T15:30:00`, type: 'training', title: '培训会 - 绩效与辅导' },
  94 + { id: 'E026', date: `${y}-${m}-19`, start: `${y}-${m}-19T10:00:00`, end: `${y}-${m}-19T11:30:00`, type: 'ops', title: '操作会 - 门店联动' },
  95 + { id: 'E027', date: `${y}-${m}-20`, start: `${y}-${m}-20T09:30:00`, end: `${y}-${m}-20T11:30:00`, type: 'meeting', title: '季度全体大会' },
  96 + { id: 'E028', date: `${y}-${m}-21`, start: `${y}-${m}-21T19:00:00`, end: `${y}-${m}-21T20:30:00`, type: 'activity', title: '门店活动 - 新品体验会' },
  97 + { id: 'E029', date: `${y}-${m}-22`, start: `${y}-${m}-22T10:00:00`, end: `${y}-${m}-22T11:30:00`, type: 'training', title: '培训会 - 工具使用' },
  98 + { id: 'E030', date: `${y}-${m}-23`, start: `${y}-${m}-23T14:00:00`, end: `${y}-${m}-23T15:30:00`, type: 'ops', title: '操作会 - 区域复盘' },
  99 + { id: 'E031', date: `${y}-${m}-24`, start: `${y}-${m}-24T15:00:00`, end: `${y}-${m}-24T16:30:00`, type: 'activity', title: '门店活动 - 老客回访日' },
  100 + { id: 'E032', date: `${y}-${m}-25`, start: `${y}-${m}-25T09:30:00`, end: `${y}-${m}-25T11:00:00`, type: 'meeting', title: '高管经营例会' }
  101 + ],
  102 + calendarPlugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
  103 + calendarEvents: [],
  104 + calendarHeader: {
  105 + left: 'prev,next today',
  106 + center: 'title',
  107 + right: 'dayGridMonth,timeGridWeek,timeGridDay'
  108 + },
  109 + buttonText: { today: '今日', month: '月', week: '周', day: '日' },
  110 + startTime: null,
  111 + endTime: null,
  112 + calendarHeight: 720,
  113 + typeColorMap: {
  114 + ops: '#6366f1',
  115 + training: '#22c55e',
  116 + meeting: '#f97316',
  117 + activity: '#ec4899'
  118 + }
  119 + }
  120 + },
  121 + computed: {
  122 + visibleProxy: {
  123 + get() {
  124 + return this.visible
  125 + },
  126 + set(v) {
  127 + this.$emit('update:visible', v)
  128 + }
  129 + },
  130 + legendList() {
  131 + return [
  132 + { type: 'ops', label: '操作会', color: this.typeColorMap.ops },
  133 + { type: 'training', label: '培训会', color: this.typeColorMap.training },
  134 + { type: 'meeting', label: '全体大会', color: this.typeColorMap.meeting },
  135 + { type: 'activity', label: '门店活动', color: this.typeColorMap.activity }
  136 + ]
  137 + }
  138 + },
  139 + watch: {
  140 + visible(v) {
  141 + if (v) {
  142 + this.$nextTick(() => {
  143 + this.calcHeight()
  144 + this.initData()
  145 + })
  146 + }
  147 + }
  148 + },
  149 + mounted() {
  150 + window.addEventListener('resize', this.calcHeight)
  151 + },
  152 + beforeDestroy() {
  153 + window.removeEventListener('resize', this.calcHeight)
  154 + },
  155 + methods: {
  156 + calcHeight() {
  157 + this.$nextTick(() => {
  158 + const el = this.$el && this.$el.querySelector('.dialog-content')
  159 + if (el) {
  160 + // 提高最小高度,让每天能展示更多事件
  161 + this.calendarHeight = Math.max(el.clientHeight - 20, 650)
  162 + }
  163 + })
  164 + },
  165 + datesRender(info) {
  166 + const view = info.view
  167 + this.startTime = view.activeStart
  168 + this.endTime = view.activeEnd
  169 + this.initData()
  170 + },
  171 + initData() {
  172 + this.loading = true
  173 + setTimeout(() => {
  174 + let filtered = [...this.companyEvents]
  175 + if (this.startTime && this.endTime) {
  176 + filtered = filtered.filter(e => {
  177 + const t = new Date(e.start).getTime()
  178 + return t >= this.startTime.getTime() && t < this.endTime.getTime()
  179 + })
  180 + }
  181 + this.calendarEvents = filtered.map(evt => {
  182 + const color = this.typeColorMap[evt.type] || '#3b82f6'
  183 + return {
  184 + id: evt.id,
  185 + title: evt.title,
  186 + start: new Date(evt.start).toISOString(),
  187 + end: new Date(evt.end).toISOString(),
  188 + color,
  189 + editable: false,
  190 + allDay: false,
  191 + extendedProps: {
  192 + type: evt.type
  193 + }
  194 + }
  195 + })
  196 + this.loading = false
  197 + }, 200)
  198 + },
  199 + handleEventClick(info) {
  200 + const evt = info && info.event
  201 + if (!evt) return
  202 + const raw = this.companyEvents.find(e => e.id === evt.id) || {}
  203 + const start = evt.start
  204 + const end = evt.end
  205 + const fmt = d => {
  206 + if (!d) return ''
  207 + const hh = String(d.getHours()).padStart(2, '0')
  208 + const mm = String(d.getMinutes()).padStart(2, '0')
  209 + return `${hh}:${mm}`
  210 + }
  211 + const timeRange = start ? `${fmt(start)} - ${fmt(end || start)}` : ''
  212 + const type = (evt.extendedProps && evt.extendedProps.type) || ''
  213 + const typeText = this.legendList.find(x => x.type === type)?.label || '安排'
  214 + const dateStr = start ? this.formatDate(start) : ''
  215 + const subject = raw.title || evt.title || '—'
  216 + const content = raw.desc || '—'
  217 + const otherParts = []
  218 + if (raw.store) otherParts.push(`门店/地点:${raw.store}`)
  219 + otherParts.push(`类型:${typeText}`)
  220 + const otherText = otherParts.join(';')
  221 +
  222 + const html = `
  223 + <div style="line-height:1.7;font-size:13px;color:#4b5563;">
  224 + <div><strong>主题:</strong>${subject}</div>
  225 + <div><strong>内容:</strong>${content}</div>
  226 + <div><strong>时间:</strong>${dateStr} ${timeRange}</div>
  227 + <div><strong>其他:</strong>${otherText}</div>
  228 + </div>
  229 + `
  230 + this.$alert(html, '日程详情', {
  231 + confirmButtonText: '知道了',
  232 + dangerouslyUseHTMLString: true
  233 + })
  234 + }
  235 + }
  236 +}
  237 +</script>
  238 +
  239 +<style lang="scss">
  240 +@import '~@fullcalendar/core/main.css';
  241 +@import '~@fullcalendar/daygrid/main.css';
  242 +@import '~@fullcalendar/timegrid/main.css';
  243 +
  244 +.company-calendar-dialog .company-calendar {
  245 + .fc-toolbar.fc-header-toolbar {
  246 + padding: 16px 20px;
  247 + margin-bottom: 0;
  248 + border-bottom: 2px solid #f1f5f9;
  249 + background: linear-gradient(135deg, rgba(59,130,246,0.02) 0%, rgba(96,165,250,0.02) 100%);
  250 + }
  251 + .fc-toolbar-title {
  252 + font-size: 18px;
  253 + font-weight: 700;
  254 + color: #1e293b;
  255 + }
  256 + .fc-button-primary {
  257 + background-color: #3b82f6;
  258 + border-color: #3b82f6;
  259 + border-radius: 10px;
  260 + font-size: 13px;
  261 + font-weight: 500;
  262 + height: 34px;
  263 + line-height: 34px;
  264 + padding: 0 14px;
  265 + transition: all 0.2s;
  266 + &:hover {
  267 + background: #2563eb;
  268 + transform: translateY(-1px);
  269 + box-shadow: 0 4px 12px rgba(59,130,246,0.3);
  270 + }
  271 + }
  272 + .fc-button-primary:not(:disabled):active,
  273 + .fc-button-primary:not(:disabled).fc-button-active {
  274 + background-color: #2563eb;
  275 + border-color: #2563eb;
  276 + box-shadow: 0 4px 12px rgba(59,130,246,0.3);
  277 + }
  278 + .fc-day-today {
  279 + background: linear-gradient(135deg, rgba(59,130,246,0.05) 0%, rgba(96,165,250,0.05) 100%);
  280 + .fc-daygrid-day-number {
  281 + background: linear-gradient(135deg, #3b82f6, #2563eb);
  282 + color: #fff;
  283 + border-radius: 6px;
  284 + box-shadow: 0 2px 8px rgba(59,130,246,0.3);
  285 + }
  286 + }
  287 + .fc-event {
  288 + cursor: pointer;
  289 + border-radius: 6px;
  290 + padding: 3px 6px;
  291 + font-size: 12px;
  292 + font-weight: 500;
  293 + border: none;
  294 + box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  295 + transition: all 0.2s;
  296 + &:hover {
  297 + transform: translateY(-1px);
  298 + box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  299 + }
  300 + }
  301 + .fc-day-header {
  302 + font-size: 14px !important;
  303 + color: #64748b !important;
  304 + font-weight: 700 !important;
  305 + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%) !important;
  306 + border-bottom: 2px solid #e2e8f0 !important;
  307 + padding: 12px 8px !important;
  308 + }
  309 + .fc-unthemed th,
  310 + .fc-unthemed td {
  311 + border-color: #f1f5f9;
  312 + }
  313 +}
  314 +</style>
  315 +
  316 +<style lang="scss" scoped>
  317 +::v-deep .company-calendar-dialog {
  318 + max-width: 1600px;
  319 + margin-top: 3vh !important;
  320 + border-radius: 20px;
  321 + padding: 0;
  322 + background: radial-gradient(circle at 0 0, rgba(255,255,255,0.96) 0, rgba(248,250,252,0.98) 40%, rgba(241,245,249,0.98) 100%);
  323 + box-shadow: 0 24px 48px rgba(15,23,42,0.18), 0 0 0 1px rgba(255,255,255,0.9);
  324 + backdrop-filter: blur(22px);
  325 + -webkit-backdrop-filter: blur(22px);
  326 +}
  327 +::v-deep .el-dialog__header { display: none; }
  328 +::v-deep .el-dialog__body { padding: 0; }
  329 +
  330 +.dialog-inner {
  331 + display: flex;
  332 + flex-direction: column;
  333 + max-height: 92vh;
  334 + height: 88vh;
  335 +}
  336 +
  337 +.dialog-header {
  338 + flex-shrink: 0;
  339 + display: flex;
  340 + align-items: center;
  341 + justify-content: space-between;
  342 + margin: 18px 22px 0;
  343 + padding: 10px 14px;
  344 + border-radius: 14px;
  345 + background: rgba(219,234,254,0.96);
  346 +}
  347 +
  348 +.dialog-title {
  349 + font-size: 17px;
  350 + font-weight: 600;
  351 + color: #0f172a;
  352 +}
  353 +
  354 +.dialog-legend {
  355 + display: flex;
  356 + align-items: center;
  357 + gap: 10px;
  358 + font-size: 12px;
  359 + color: #6b7280;
  360 +}
  361 +
  362 +.legend-item {
  363 + display: inline-flex;
  364 + align-items: center;
  365 + gap: 4px;
  366 +}
  367 +
  368 +.legend-dot {
  369 + width: 10px;
  370 + height: 10px;
  371 + border-radius: 999px;
  372 +}
  373 +
  374 +.dialog-close {
  375 + cursor: pointer;
  376 + width: 28px;
  377 + height: 28px;
  378 + display: flex;
  379 + align-items: center;
  380 + justify-content: center;
  381 + border-radius: 999px;
  382 + color: #64748b;
  383 + transition: all 0.15s;
  384 + &:hover {
  385 + background: rgba(0,0,0,0.06);
  386 + color: #0f172a;
  387 + }
  388 +}
  389 +
  390 +.dialog-content {
  391 + flex: 1;
  392 + min-height: 0;
  393 + overflow: hidden;
  394 + padding: 0 22px 14px;
  395 +}
  396 +</style>
  397 +
  398 +
... ...
store-pc/src/components/InviteAddDialog.vue
... ... @@ -10,7 +10,7 @@
10 10 <div class="invite-dialog-inner">
11 11 <div class="invite-header">
12 12 <div class="invite-title-wrap">
13   - <div class="invite-title">新增邀约记录</div>
  13 + <div class="invite-title">新增沟通记录</div>
14 14 </div>
15 15 </div>
16 16  
... ... @@ -22,44 +22,28 @@
22 22 size="small"
23 23 class="invite-form"
24 24 >
25   - <el-form-item label="邀约时间" prop="yysj">
26   - <el-date-picker
27   - v-model="form.yysj"
28   - type="datetime"
29   - placeholder="请选择邀约时间"
30   - style="width: 100%"
31   - />
32   - </el-form-item>
33   -
34   - <el-form-item label="预约客户" prop="yykh">
  25 + <el-form-item label="沟通方式" prop="gtfs">
35 26 <el-select
36   - v-model="form.yykh"
37   - placeholder="请选择预约客户"
  27 + v-model="form.gtfs"
  28 + placeholder="请选择沟通方式"
38 29 filterable
39 30 clearable
40 31 >
41   - <el-option
42   - v-for="item in customerOptions"
43   - :key="item.value"
44   - :label="item.label"
45   - :value="item.value"
46   - />
  32 + <el-option label="电话" value="电话" />
  33 + <el-option label="微信" value="微信" />
47 34 </el-select>
48 35 </el-form-item>
49 36  
50   - <el-form-item label="联系电话">
51   - <el-input
52   - v-model="form.dh"
53   - placeholder="电话号码"
54   - disabled
55   - />
56   - </el-form-item>
57   -
58   - <el-form-item label="电话是否有效" prop="dhsfyx">
59   - <el-radio-group v-model="form.dhsfyx">
60   - <el-radio label="是">是</el-radio>
61   - <el-radio label="否">否</el-radio>
62   - </el-radio-group>
  37 + <el-form-item label="是否接听" prop="sfjt">
  38 + <el-select
  39 + v-model="form.sfjt"
  40 + placeholder="请选择是否接听"
  41 + clearable
  42 + >
  43 + <el-option label="是" value="是" />
  44 + <el-option label="否" value="否" />
  45 + <el-option label="未拨通" value="未拨通" />
  46 + </el-select>
63 47 </el-form-item>
64 48  
65 49 <el-form-item label="联系记录" prop="lxjl">
... ... @@ -70,6 +54,15 @@
70 54 placeholder="请输入联系记录"
71 55 />
72 56 </el-form-item>
  57 +
  58 + <el-form-item label="下次联系时间" prop="xcLxsj">
  59 + <el-date-picker
  60 + v-model="form.xcLxsj"
  61 + type="datetime"
  62 + placeholder="可选,方便下次跟进"
  63 + style="width: 100%"
  64 + />
  65 + </el-form-item>
73 66 </el-form>
74 67  
75 68 <div class="invite-footer">
... ... @@ -97,11 +90,10 @@ export default {
97 90 return {
98 91 submitting: false,
99 92 form: {
100   - yysj: '',
101   - yykh: '',
102   - dh: '',
103   - dhsfyx: '',
104   - lxjl: ''
  93 + gtfs: '',
  94 + sfjt: '',
  95 + lxjl: '',
  96 + xcLxsj: ''
105 97 },
106 98 customerOptions: [
107 99 { label: '张三', value: 'GK2020082101170', phone: '13800000001' },
... ... @@ -109,9 +101,8 @@ export default {
109 101 { label: '王五', value: 'GK2020082101172', phone: '13800000003' }
110 102 ],
111 103 rules: {
112   - yysj: [{ required: true, message: '请选择邀约时间', trigger: 'change' }],
113   - yykh: [{ required: true, message: '请选择预约客户', trigger: 'change' }],
114   - dhsfyx: [{ required: true, message: '请选择电话是否有效', trigger: 'change' }],
  104 + gtfs: [{ required: true, message: '请选择沟通方式', trigger: 'change' }],
  105 + sfjt: [{ required: true, message: '请选择是否接听', trigger: 'change' }],
115 106 lxjl: [
116 107 { required: true, message: '请输入联系记录', trigger: 'blur' },
117 108 { min: 2, message: '联系记录至少 2 个字', trigger: 'blur' }
... ... @@ -130,19 +121,14 @@ export default {
130 121 }
131 122 },
132 123 watch: {
133   - 'form.yykh'(val) {
134   - const target = this.customerOptions.find(item => item.value === val)
135   - this.form.dh = target ? target.phone : ''
136   - }
137 124 },
138 125 methods: {
139 126 resetForm() {
140 127 this.form = {
141   - yysj: '',
142   - yykh: '',
143   - dh: '',
144   - dhsfyx: '',
145   - lxjl: ''
  128 + gtfs: '',
  129 + sfjt: '',
  130 + lxjl: '',
  131 + xcLxsj: ''
146 132 }
147 133 this.$refs.form && this.$refs.form.clearValidate()
148 134 },
... ... @@ -156,7 +142,7 @@ export default {
156 142 this.submitting = true
157 143 setTimeout(() => {
158 144 this.submitting = false
159   - this.$message.success('邀约记录已保存(示例)')
  145 + this.$message.success('已添加沟通记录')
160 146 this.visibleProxy = false
161 147 this.resetForm()
162 148 }, 800)
... ...
store-pc/src/components/MemberProfileDialog.vue
... ... @@ -297,7 +297,7 @@
297 297 <!-- 底部按钮 -->
298 298 <div class="content-footer">
299 299 <span class="records-op-btn records-op-btn--ghost records-op-btn--invite" @click="emitAction('invite')"><i
300   - class="el-icon-phone-outline"></i>去邀约</span>
  300 + class="el-icon-phone-outline"></i>添加沟通记录</span>
301 301 <span class="records-op-btn records-op-btn--ghost records-op-btn--booking" @click="emitAction('booking')"><i
302 302 class="el-icon-date"></i>去预约</span>
303 303 <span class="records-op-btn records-op-btn--ghost records-op-btn--billing" @click="emitAction('billing')"><i
... ... @@ -590,7 +590,7 @@ export default {
590 590 recordTabs() {
591 591 return [
592 592 { key: 'rights', label: '权益明细' },
593   - { key: 'invite', label: '邀约记录' },
  593 + { key: 'invite', label: '联系记录' },
594 594 { key: 'booking', label: '预约记录' },
595 595 { key: 'billing', label: '开单记录' },
596 596 { key: 'consume', label: '消耗记录' },
... ... @@ -628,7 +628,7 @@ export default {
628 628 },
629 629 currentSearchPlaceholder() {
630 630 switch (this.recordsTab) {
631   - case 'invite': return '按联系记录/邀约人搜索'
  631 + case 'invite': return '按联系记录/跟进人搜索'
632 632 case 'booking': return '按体验项目/健康师搜索'
633 633 case 'billing': return '按品项/开单人员搜索'
634 634 case 'consume': return '按品项/操作人员搜索'
... ...
store-pc/src/views/dashboard/index.vue
... ... @@ -5,55 +5,45 @@
5 5 <div class="store-info">
6 6 <div class="store-name">{{ currentStoreName }} · 工作台</div>
7 7 <div class="store-subtitle">
8   - 今日邀约 · 预约 · 开单,一眼总览
  8 + 今日预约 · 开单,一眼总览
9 9 <span class="schedule-link" @click.stop="$store.commit('SET_SCHEDULE_DIALOG', true)">打开排班</span>
10 10 </div>
11 11 </div>
12 12 <div class="top-bar-right">
13   - <div class="reminder-inline">
14   - <i class="el-icon-warning-outline reminder-icon"></i>
15   - <span class="reminder-label">待办:</span>
16   - <span class="reminder-item" @click="handleReminderClick('goldTriangle')">请设置下月金三角</span>
17   - <span class="reminder-sep">、</span>
18   - <span class="reminder-item" @click="handleReminderClick('storeTarget')">请设置门店目标</span>
  13 + <div class="calendar-entry" @click="companyCalendarVisible = true">
  14 + <i class="el-icon-date calendar-entry-icon"></i>
  15 + <span class="calendar-entry-text">公司日历</span>
19 16 </div>
20 17 <div class="member-search">
21   - <el-autocomplete
22   - v-model="memberKeyword"
23   - class="member-search-input"
24   - :fetch-suggestions="fetchMemberSuggestions"
25   - placeholder="手机号 / 姓名 / 会员卡号"
26   - prefix-icon="el-icon-search"
27   - value-key="displayText"
28   - :trigger-on-focus="true"
29   - popper-class="member-search-dropdown"
30   - @select="handleMemberSelect"
31   - @keyup.enter.native="handleFirstMatchOrFocus"
32   - >
33   - <template slot-scope="{ item }">
34   - <div class="member-suggestion">
35   - <div class="suggestion-main">
36   - <span class="suggestion-name">{{ item.khmc }}</span>
37   - <span class="suggestion-level" :class="'level--' + (item.levelKey || 'default')">{{ item.level }}</span>
38   - </div>
39   - <div class="suggestion-meta">
40   - <span><i class="el-icon-phone"></i>{{ item.sjh }}</span>
41   - <span v-if="item.dah"><i class="el-icon-postcard"></i>{{ item.dah }}</span>
42   - <span><i class="el-icon-office-building"></i>{{ item.gsmdName }}</span>
43   - </div>
44   - <div class="suggestion-extra">
45   - <span class="suggestion-row">
46   - <span>最后到店:{{ item.lastVisit }}</span>
47   - <span v-if="item.sleepDays > 0" class="suggestion-sleep">沉睡 {{ item.sleepDays }} 天</span>
48   - </span>
49   - <span class="suggestion-row">
50   - <span>剩余权益:<b>¥{{ item.remainingAmount }}</b></span>
51   - <span>累计消费:¥{{ item.totalConsume }}</span>
52   - </span>
  18 + <el-autocomplete v-model="memberKeyword" class="member-search-input"
  19 + :fetch-suggestions="fetchMemberSuggestions" placeholder="手机号 / 姓名 / 会员卡号" prefix-icon="el-icon-search"
  20 + value-key="displayText" :trigger-on-focus="true" popper-class="member-search-dropdown"
  21 + @select="handleMemberSelect" @keyup.enter.native="handleFirstMatchOrFocus">
  22 + <template slot-scope="{ item }">
  23 + <div class="member-suggestion">
  24 + <div class="suggestion-main">
  25 + <span class="suggestion-name">{{ item.khmc }}</span>
  26 + <span class="suggestion-level" :class="'level--' + (item.levelKey || 'default')">{{ item.level
  27 + }}</span>
  28 + </div>
  29 + <div class="suggestion-meta">
  30 + <span><i class="el-icon-phone"></i>{{ item.sjh }}</span>
  31 + <span v-if="item.dah"><i class="el-icon-postcard"></i>{{ item.dah }}</span>
  32 + <span><i class="el-icon-office-building"></i>{{ item.gsmdName }}</span>
  33 + </div>
  34 + <div class="suggestion-extra">
  35 + <span class="suggestion-row">
  36 + <span>最后到店:{{ item.lastVisit }}</span>
  37 + <span v-if="item.sleepDays > 0" class="suggestion-sleep">沉睡 {{ item.sleepDays }} 天</span>
  38 + </span>
  39 + <span class="suggestion-row">
  40 + <span>剩余权益:<b>¥{{ item.remainingAmount }}</b></span>
  41 + <span>累计消费:¥{{ item.totalConsume }}</span>
  42 + </span>
  43 + </div>
53 44 </div>
54   - </div>
55   - </template>
56   - </el-autocomplete>
  45 + </template>
  46 + </el-autocomplete>
57 47 </div>
58 48 </div>
59 49 <div class="top-meta">
... ... @@ -104,15 +94,29 @@
104 94 <div class="left-column">
105 95 <!-- 今日概览 -->
106 96 <div class="overview-row">
107   - <el-row :gutter="16">
108   - <el-col v-for="item in overviewStats" :key="item.key" :span="8">
109   - <el-card shadow="hover" class="overview-card">
110   - <div class="overview-meta">
111   - <span class="label">{{ item.label }}</span>
112   - <span class="tag" v-if="item.tag">{{ item.tag }}</span>
  97 + <el-row :gutter="16" class="overview-row-inner">
  98 + <el-col
  99 + v-for="item in overviewStats"
  100 + :key="item.key"
  101 + :span="24 / overviewStats.length"
  102 + >
  103 + <el-card
  104 + shadow="hover"
  105 + :class="['overview-card', 'overview-card--' + item.key]"
  106 + >
  107 + <div class="overview-card-inner">
  108 + <div class="overview-meta">
  109 + <span class="label">{{ item.label }}</span>
  110 + <span class="tag" v-if="item.tag">{{ item.tag }}</span>
  111 + </div>
  112 + <div class="overview-main">
  113 + <div class="overview-main-value">{{ item.today }}</div>
  114 + </div>
  115 + <div class="overview-secondary">
  116 + <span class="overview-secondary-label">本月</span>
  117 + <span class="overview-secondary-value">{{ item.month }}</span>
  118 + </div>
113 119 </div>
114   - <div class="overview-value">{{ item.value }}</div>
115   - <div class="overview-sub">{{ item.subText }}</div>
116 120 </el-card>
117 121 </el-col>
118 122 </el-row>
... ... @@ -162,8 +166,11 @@
162 166 </div>
163 167 <div class="module-actions-spacer"></div>
164 168 <div class="module-actions">
165   - <button type="button" class="primary-action" @click.stop="handleModulePrimary(module)">{{ module.primaryAction }}</button>
166   - <span v-if="module.secondaryText" class="secondary-text" @click.stop="handleModuleSecondary(module)">{{ module.secondaryText }}</span>
  169 + <button type="button" class="primary-action" @click.stop="handleModulePrimary(module)">{{
  170 + module.primaryAction }}</button>
  171 + <span v-if="module.secondaryText" class="secondary-text"
  172 + @click.stop="handleModuleSecondary(module)">{{
  173 + module.secondaryText }}</span>
167 174 </div>
168 175 </el-card>
169 176 </el-col>
... ... @@ -171,132 +178,182 @@
171 178 </div>
172 179 </div>
173 180  
174   - <!-- 右侧:今日邀约 / 预约 -->
175   - <div class="right-column">
176   - <el-card shadow="hover" class="today-card" :body-style="{ padding: '16px 16px 12px' }">
177   - <el-tabs v-model="todayTab">
178   - <el-tab-pane label="今日邀约" name="invite">
179   - <div v-if="todayInvite.length" class="timeline-list">
180   - <div v-for="item in todayInvite" :key="item.id" class="timeline-item">
181   - <div class="time">{{ item.time }}</div>
182   - <div class="content">
183   - <div class="line-main">
184   - <span class="name">{{ item.name }}</span>
185   - <span class="mobile">{{ item.mobile }}</span>
  181 + <!-- 提醒中心侧栏(今日邀约 / 今日预约 / 生日提醒 / 沉睡提醒) -->
  182 + <div class="reminder-center">
  183 + <!-- 右侧窄栏入口 -->
  184 + <div class="reminder-center-toggle">
  185 + <div v-for="entry in reminderEntries" :key="entry.key"
  186 + :class="['reminder-toggle-item', entry.key === 'todo' ? 'todo-entry' : '', { 'is-active': currentReminderType === entry.key && reminderCenterOpen }]"
  187 + @click="handleReminderEntryClick(entry.key)">
  188 + <!-- 提醒中心入口样式:图标在上、文字在下(角标显示数字) -->
  189 + <el-badge
  190 + :value="entry.count"
  191 + :max="99"
  192 + :hidden="entry.count === 0"
  193 + class="reminder-badge"
  194 + >
  195 + <div :class="['entry-icon-wrap', 'entry-icon-wrap--' + entry.key]">
  196 + <i :class="['entry-icon', entry.icon]"></i>
  197 + </div>
  198 + </el-badge>
  199 + <span class="label-text">{{ entry.label }}</span>
  200 + </div>
  201 + <div v-if="reminderCenterOpen" class="reminder-toggle-close" @click.stop="toggleReminderCenter(false)">
  202 + <i class="el-icon-arrow-right"></i>
  203 + </div>
  204 + </div>
  205 +
  206 + <!-- 侧栏主体 -->
  207 + <transition name="reminder-center-slide" :css="reminderFirstRenderDone">
  208 + <div v-if="reminderCenterOpen" class="reminder-center-panel">
  209 + <el-card shadow="hover" class="today-card" :body-style="{ padding: '16px 16px 12px' }">
  210 + <el-tabs v-model="todayTab" @tab-click="handleTodayTabClick">
  211 + <el-tab-pane label="我的待办" name="todo">
  212 + <div v-if="todoList.length" class="timeline-list todo-list">
  213 + <div v-for="item in todoList" :key="item.key" class="timeline-item todo-item" @click="handleTodoClick(item)">
  214 + <div class="time">{{ item.tag }}</div>
  215 + <div class="content">
  216 + <div class="line-main">
  217 + <span class="name">{{ item.title }}</span>
  218 + </div>
  219 + <div class="line-sub">
  220 + <span class="project">{{ item.desc }}</span>
  221 + <button
  222 + type="button"
  223 + class="quick-book-btn"
  224 + >
  225 + 立即处理
  226 + </button>
  227 + </div>
  228 + </div>
186 229 </div>
187   - <div class="line-sub">
188   - <span class="project">
189   - 电话是否有效:{{ item.dhsfyx }} · {{ item.lxjl }}
190   - </span>
191   - <button type="button" class="quick-book-btn" @click="handleQuickBooking(item)">
192   - 快速预约
193   - </button>
  230 + </div>
  231 + <div v-else class="empty-tip">暂无待办事项</div>
  232 + </el-tab-pane>
  233 + <el-tab-pane label="今日预约" name="booking">
  234 + <div v-if="todayBooking.length" class="timeline-list">
  235 + <div v-for="item in todayBooking" :key="item.id" class="timeline-item">
  236 + <div class="time">{{ item.time }}</div>
  237 + <div class="content">
  238 + <div class="line-main">
  239 + <span class="name">{{ item.name }}</span>
  240 + <span class="mobile">{{ item.mobile }}</span>
  241 + </div>
  242 + <div class="line-sub">
  243 + <span class="project">
  244 + 预约项目:{{ item.project }} · 预约时间:{{ item.date }} {{ item.timeRange }}
  245 + </span>
  246 + <div class="booking-actions">
  247 + <el-button
  248 + type="text"
  249 + size="mini"
  250 + class="booking-action-btn booking-action-btn--cancel"
  251 + @click.stop="handleCancelBooking(item)"
  252 + >
  253 + 取消预约
  254 + </el-button>
  255 + <el-button
  256 + type="text"
  257 + size="mini"
  258 + class="booking-action-btn booking-action-btn--confirm"
  259 + @click.stop="handleConfirmConsume(item)"
  260 + >
  261 + 确认消耗
  262 + </el-button>
  263 + </div>
  264 + </div>
  265 + </div>
194 266 </div>
195 267 </div>
196   - </div>
197   - </div>
198   - <div v-else class="empty-tip">今日暂无邀约记录</div>
199   - </el-tab-pane>
200   - <el-tab-pane label="今日预约" name="booking">
201   - <div v-if="todayBooking.length" class="timeline-list">
202   - <div v-for="item in todayBooking" :key="item.id" class="timeline-item">
203   - <div class="time">{{ item.time }}</div>
204   - <div class="content">
205   - <div class="line-main">
206   - <span class="name">{{ item.name }}</span>
207   - <span class="mobile">{{ item.mobile }}</span>
  268 + <div v-else class="empty-tip">今日暂无预约记录</div>
  269 + </el-tab-pane>
  270 + <el-tab-pane label="生日提醒" name="birthday">
  271 + <div class="birthday-calendar">
  272 + <!-- 月份切换头 -->
  273 + <div class="cal-header">
  274 + <button class="cal-nav-btn" @click="prevBirthdayMonth"><i class="el-icon-arrow-left"></i></button>
  275 + <div class="cal-month-label">{{ birthdayYear }} 年 {{ birthdayMonth }} 月</div>
  276 + <button class="cal-nav-btn" @click="nextBirthdayMonth"><i
  277 + class="el-icon-arrow-right"></i></button>
208 278 </div>
209   - <div class="line-sub">
210   - <span class="project">
211   - 预约项目:{{ item.project }} · 预约时间:{{ item.date }} {{ item.timeRange }}
212   - </span>
213   - <button type="button" class="quick-order-btn" @click="handleQuickBilling(item)">
214   - 快速开单
215   - </button>
  279 + <!-- 星期头 -->
  280 + <div class="cal-weekdays">
  281 + <div v-for="w in ['日', '一', '二', '三', '四', '五', '六']" :key="w" class="cal-weekday">{{ w }}</div>
216 282 </div>
217   - </div>
218   - </div>
219   - </div>
220   - <div v-else class="empty-tip">今日暂无预约记录</div>
221   - </el-tab-pane>
222   - <el-tab-pane label="生日提醒" name="birthday">
223   - <div class="birthday-calendar">
224   - <!-- 月份切换头 -->
225   - <div class="cal-header">
226   - <button class="cal-nav-btn" @click="prevBirthdayMonth"><i class="el-icon-arrow-left"></i></button>
227   - <div class="cal-month-label">{{ birthdayYear }} 年 {{ birthdayMonth }} 月</div>
228   - <button class="cal-nav-btn" @click="nextBirthdayMonth"><i class="el-icon-arrow-right"></i></button>
229   - </div>
230   - <!-- 星期头 -->
231   - <div class="cal-weekdays">
232   - <div v-for="w in ['日','一','二','三','四','五','六']" :key="w" class="cal-weekday">{{ w }}</div>
233   - </div>
234   - <!-- 日期格子 -->
235   - <div class="cal-grid">
236   - <div v-for="(cell, idx) in calendarDays" :key="idx"
237   - :class="['cal-cell', { 'cal-cell--empty': !cell.day, 'cal-cell--has-birthday': cell.members.length > 0, 'cal-cell--selected': cell.day === selectedBirthdayDay }]"
238   - @click="cell.day && selectBirthdayDay(cell.day)">
239   - <span v-if="cell.day" class="cal-day-num">{{ cell.day }}</span>
240   - <div v-if="cell.members.length" class="cal-dots">
241   - <span v-for="m in cell.members.slice(0, 3)" :key="m.id"
242   - class="cal-dot" :style="{ background: levelDotColor(m.level) }"
  283 + <!-- 日期格子 -->
  284 + <div class="cal-grid">
  285 + <div v-for="(cell, idx) in calendarDays" :key="idx"
  286 + :class="['cal-cell', { 'cal-cell--empty': !cell.day, 'cal-cell--has-birthday': cell.members.length > 0, 'cal-cell--selected': cell.day === selectedBirthdayDay }]"
  287 + @click="cell.day && selectBirthdayDay(cell.day)">
  288 + <span v-if="cell.day" class="cal-day-num">{{ cell.day }}</span>
  289 + <div v-if="cell.members.length" class="cal-dots">
  290 + <span v-for="m in cell.members.slice(0, 3)" :key="m.id" class="cal-dot"
  291 + :style="{ background: levelDotColor(m.level) }"
243 292 :title="m.name + '(' + m.level + ')'"></span>
  293 + </div>
  294 + </div>
244 295 </div>
245   - </div>
246   - </div>
247   - <!-- 生日会员列表 -->
248   - <div class="cal-list-title" v-if="birthdayMonthData.length">
249   - <span v-if="selectedBirthdayDay != null">{{ birthdayMonth }}月{{ selectedBirthdayDay }}日 · {{ birthdayDisplayList.length }} 位</span>
250   - <span v-else>本月共 {{ birthdayMonthData.length }} 位会员生日</span>
251   - <span v-if="selectedBirthdayDay != null" class="cal-list-reset" @click="selectedBirthdayDay = null">查看全部</span>
252   - </div>
253   - <div v-if="birthdayDisplayList.length" class="timeline-list cal-member-list">
254   - <div v-for="item in birthdayDisplayList" :key="item.id" class="birthday-item">
255   - <div class="birthday-day-badge">{{ item.day }}日</div>
256   - <div class="birthday-info">
257   - <div class="birthday-name">
258   - {{ item.name }}
259   - <span class="birthday-level-tag" :style="levelTagStyle(item.level)">{{ item.level }}</span>
  296 + <!-- 生日会员列表 -->
  297 + <div class="cal-list-title" v-if="birthdayMonthData.length">
  298 + <span v-if="selectedBirthdayDay != null">{{ birthdayMonth }}月{{ selectedBirthdayDay }}日 · {{
  299 + birthdayDisplayList.length }} 位</span>
  300 + <span v-else>本月共 {{ birthdayMonthData.length }} 位会员生日</span>
  301 + <span v-if="selectedBirthdayDay != null" class="cal-list-reset"
  302 + @click="selectedBirthdayDay = null">查看全部</span>
  303 + </div>
  304 + <div v-if="birthdayDisplayList.length" class="timeline-list cal-member-list">
  305 + <div v-for="item in birthdayDisplayList" :key="item.id" class="birthday-item">
  306 + <div class="birthday-day-badge">{{ item.day }}日</div>
  307 + <div class="birthday-info">
  308 + <div class="birthday-name">
  309 + {{ item.name }}
  310 + <span class="birthday-level-tag" :style="levelTagStyle(item.level)">{{ item.level }}</span>
  311 + </div>
  312 + <div class="birthday-phone">{{ item.phone }}</div>
  313 + <div class="birthday-amount">累计消费 ¥{{ item.totalAmount }}</div>
  314 + </div>
  315 + <button class="quick-book-btn" @click="handleBirthdayCall(item)">发祝福</button>
260 316 </div>
261   - <div class="birthday-phone">{{ item.phone }}</div>
262   - <div class="birthday-amount">累计消费 ¥{{ item.totalAmount }}</div>
263 317 </div>
264   - <button class="quick-book-btn" @click="handleBirthdayCall(item)">发祝福</button>
  318 + <div v-else-if="birthdayMonthData.length && selectedBirthdayDay != null" class="empty-tip">{{
  319 + birthdayMonth }}月{{ selectedBirthdayDay }}日暂无会员生日</div>
  320 + <div v-else class="empty-tip">本月暂无会员生日</div>
265 321 </div>
266   - </div>
267   - <div v-else-if="birthdayMonthData.length && selectedBirthdayDay != null" class="empty-tip">{{ birthdayMonth }}月{{ selectedBirthdayDay }}日暂无会员生日</div>
268   - <div v-else class="empty-tip">本月暂无会员生日</div>
269   - </div>
270   - </el-tab-pane>
271   - <el-tab-pane label="沉睡提醒" name="sleeping">
272   - <div v-if="sleepingMembers.length" class="timeline-list">
273   - <div v-for="item in sleepingMembers" :key="item.id" class="sleeping-item">
274   - <div class="sleeping-days">{{ item.days }}天</div>
275   - <div class="sleeping-info">
276   - <div class="sleeping-name">{{ item.name }}
277   - <span class="sleeping-level">{{ item.level }}</span>
  322 + </el-tab-pane>
  323 + <el-tab-pane label="沉睡提醒" name="sleeping">
  324 + <div v-if="sleepingMembers.length" class="timeline-list">
  325 + <div v-for="item in sleepingMembers" :key="item.id" class="sleeping-item">
  326 + <div class="sleeping-days">{{ item.days }}天</div>
  327 + <div class="sleeping-info">
  328 + <div class="sleeping-name">
  329 + {{ item.name }}
  330 + <span class="sleeping-level">{{ item.level }}</span>
  331 + </div>
  332 + <div class="sleeping-phone">{{ item.phone }}</div>
  333 + <div class="sleeping-last">最后到店:{{ item.lastVisit }} · 余额 ¥{{ item.balance }}</div>
  334 + </div>
  335 + <button class="quick-book-btn" @click="handleSleepingCall(item)">去邀约</button>
278 336 </div>
279   - <div class="sleeping-phone">{{ item.phone }}</div>
280   - <div class="sleeping-last">最后到店:{{ item.lastVisit }} · 余额 ¥{{ item.balance }}</div>
281 337 </div>
282   - <button class="quick-book-btn" @click="handleSleepingCall(item)">去邀约</button>
283   - </div>
284   - </div>
285   - <div v-else class="empty-tip">暂无沉睡会员</div>
286   - </el-tab-pane>
287   - </el-tabs>
288   - </el-card>
  338 + <div v-else class="empty-tip">暂无沉睡会员</div>
  339 + </el-tab-pane>
  340 + </el-tabs>
  341 + </el-card>
  342 + </div>
  343 + </transition>
289 344 </div>
290 345 </div>
291   - <member-profile-dialog :visible.sync="memberDialogVisible" :member="activeMember || {}" />
  346 + <member-profile-dialog
  347 + :visible.sync="memberDialogVisible"
  348 + :member="activeMember || {}"
  349 + @action="handleMemberAction"
  350 + />
292 351 <tuoke-lead-dialog :visible.sync="tuokeDialogVisible" />
293   - <invite-add-dialog :visible.sync="inviteDialogVisible" />
294 352 <booking-dialog :visible.sync="bookingDialogVisible" :prefill="bookingPrefill" />
295 353 <billing-dialog :visible.sync="billingDialogVisible" :prefill="billingPrefill" />
296 354 <consume-dialog :visible.sync="consumeDialogVisible" />
297 355 <refund-dialog :visible.sync="refundDialogVisible" />
298 356 <tuoke-list-dialog :visible.sync="tuokeListVisible" />
299   - <invite-list-dialog :visible.sync="inviteListVisible" />
300 357 <booking-calendar-dialog :visible.sync="bookingCalendarVisible" />
301 358 <billing-list-dialog :visible.sync="billingListVisible" />
302 359 <consume-list-dialog :visible.sync="consumeListVisible" />
... ... @@ -312,18 +369,21 @@
312 369 :current-store-name="currentStoreName"
313 370 :month-value="storeTargetMonthValue"
314 371 />
  372 + <invite-add-dialog :visible.sync="contactDialogVisible" />
  373 + <company-calendar-dialog :visible.sync="companyCalendarVisible" />
315 374 <!-- 切换门店弹窗 -->
316   - <el-dialog :visible.sync="storeSwitchDialogVisible" width="420px" append-to-body
317   - custom-class="store-switch-dialog" :show-close="false">
  375 + <el-dialog :visible.sync="storeSwitchDialogVisible" width="420px" append-to-body custom-class="store-switch-dialog"
  376 + :show-close="false">
318 377 <div class="store-switch-inner">
319 378 <div class="store-switch-header">
320 379 <div class="store-switch-title">切换门店</div>
321   - <span class="store-switch-close" @click="storeSwitchDialogVisible = false"><i class="el-icon-close"></i></span>
  380 + <span class="store-switch-close" @click="storeSwitchDialogVisible = false"><i
  381 + class="el-icon-close"></i></span>
322 382 </div>
323 383 <div class="store-switch-content">
324 384 <div v-for="s in storeList" :key="s.id"
325   - :class="['store-switch-item', { 'store-switch-item--active': s.id === currentStoreId }]"
326   - @click="handleStoreSwitch(s)">
  385 + :class="['store-switch-item', { 'store-switch-item--active': s.id === currentStoreId }]"
  386 + @click="handleStoreSwitch(s)">
327 387 <div class="store-switch-icon">
328 388 <span>{{ (s.name || '').replace(/店$/, '')[0] || '店' }}</span>
329 389 </div>
... ... @@ -348,12 +408,12 @@
348 408 import MemberProfileDialog from '@/components/MemberProfileDialog.vue'
349 409 import TuokeLeadDialog from '@/components/TuokeLeadDialog.vue'
350 410 import InviteAddDialog from '@/components/InviteAddDialog.vue'
  411 +import CompanyCalendarDialog from '@/components/CompanyCalendarDialog.vue'
351 412 import BookingDialog from '@/components/BookingDialog.vue'
352 413 import BillingDialog from '@/components/BillingDialog.vue'
353 414 import ConsumeDialog from '@/components/ConsumeDialog.vue'
354 415 import RefundDialog from '@/components/RefundDialog.vue'
355 416 import TuokeListDialog from '@/components/TuokeListDialog.vue'
356   -import InviteListDialog from '@/components/InviteListDialog.vue'
357 417 import BookingCalendarDialog from '@/components/BookingCalendarDialog.vue'
358 418 import BillingListDialog from '@/components/BillingListDialog.vue'
359 419 import ConsumeListDialog from '@/components/ConsumeListDialog.vue'
... ... @@ -367,12 +427,12 @@ export default {
367 427 MemberProfileDialog,
368 428 TuokeLeadDialog,
369 429 InviteAddDialog,
  430 + CompanyCalendarDialog,
370 431 BookingDialog,
371 432 BillingDialog,
372 433 ConsumeDialog,
373 434 RefundDialog,
374 435 TuokeListDialog,
375   - InviteListDialog,
376 436 BookingCalendarDialog,
377 437 BillingListDialog,
378 438 ConsumeListDialog,
... ... @@ -389,6 +449,8 @@ export default {
389 449 memberKeyword: '',
390 450 memberDialogVisible: false,
391 451 activeMember: null,
  452 + contactDialogVisible: false,
  453 + companyCalendarVisible: false,
392 454 storeSwitchDialogVisible: false,
393 455 currentStoreId: 'S001',
394 456 // 用户可访问门店:通常 1 个(归属店),借调时 2 个,最多 3 个
... ... @@ -415,30 +477,57 @@ export default {
415 477 { id: 'M016', khmc: '冯丽娟', sjh: '15500155723', dah: 'GK2023081501015', level: 'B', levelKey: 'b', gsmdName: '融创店', lastVisit: '2025-12-25', sleepDays: 0, remainingAmount: '0.73万', totalConsume: '7.3万' },
416 478 { id: 'M017', khmc: '蒋雪梅', sjh: '17700177634', dah: 'GK2023102201016', level: 'A+', levelKey: 'aplus', gsmdName: '西站店', lastVisit: '2026-02-01', sleepDays: 0, remainingAmount: '2.85万', totalConsume: '28.5万' }
417 479 ],
418   - todayTab: 'invite',
419   - monthlyDone: 86420,
420   - monthlyTarget: 150000,
  480 + todayTab: 'todo',
421 481 overviewStats: [
422 482 {
423   - key: 'invite',
424   - label: '今日邀约',
425   - value: 12,
426   - subText: '待联系 7 · 已到店 5',
427   - tag: '潜客'
  483 + key: 'headCount',
  484 + label: '人头数',
  485 + today: 42,
  486 + month: 260,
  487 + tag: '客流'
428 488 },
429 489 {
430   - key: 'booking',
431   - label: '今日预约',
432   - value: 8,
433   - subText: '进行中 3 · 已完成 5',
434   - tag: '到店'
  490 + key: 'visitCount',
  491 + label: '人次数',
  492 + today: 58,
  493 + month: 380,
  494 + tag: '频次'
435 495 },
436 496 {
437   - key: 'order',
438   - label: '今日业绩',
439   - value: '¥18,360',
440   - subText: '开单 6 · 客次 11',
441   - tag: '收银'
  497 + key: 'projectCount',
  498 + label: '项目数',
  499 + today: 96,
  500 + month: 1240,
  501 + tag: '项目'
  502 + },
  503 + {
  504 + key: 'consumeAmount',
  505 + label: '消耗金额',
  506 + today: '¥63,580',
  507 + month: '¥1,236,800',
  508 + tag: '消耗'
  509 + },
  510 + {
  511 + key: 'refundAmount',
  512 + label: '退卡金额',
  513 + today: '¥12,400',
  514 + month: '¥45,300',
  515 + tag: '退卡'
  516 + }
  517 + ],
  518 + // 我的待办列表(后续可扩展更多待办)
  519 + todoList: [
  520 + {
  521 + key: 'goldTriangle',
  522 + title: '请设置下月金三角',
  523 + desc: '确保下月业绩分配和责任人明确',
  524 + tag: '门店规划'
  525 + },
  526 + {
  527 + key: 'storeTarget',
  528 + title: '请设置门店目标',
  529 + desc: '设置本月/下月门店业绩目标,便于进度跟踪',
  530 + tag: '业绩目标'
442 531 }
443 532 ],
444 533 modules: [
... ... @@ -454,17 +543,6 @@ export default {
454 543 secondaryText: '查看拓客数据'
455 544 },
456 545 {
457   - key: 'invite',
458   - title: '邀约管理',
459   - subtitle: '',
460   - icon: 'el-icon-phone-outline',
461   - iconBg: 'linear-gradient(135deg, #0ea5e9, #22c55e)',
462   - metricValue: 0,
463   - metricLabel: '待跟进邀约',
464   - primaryAction: '新建邀约',
465   - secondaryText: '查看邀约记录'
466   - },
467   - {
468 546 key: 'booking',
469 547 title: '预约管理',
470 548 subtitle: '',
... ... @@ -540,88 +618,7 @@ export default {
540 618 secondaryText: ''
541 619 }
542 620 ],
543   - todayInvite: [
544   - {
545   - id: 'TI202603020001',
546   - time: '09:10',
547   - name: '林小纤',
548   - mobile: '13800138000',
549   - dhsfyx: '是',
550   - lxjl: '邀约周末面部深层护理体验,客户有兴趣。'
551   - },
552   - {
553   - id: 'TI202603020002',
554   - time: '09:35',
555   - name: '王丽',
556   - mobile: '13800138001',
557   - dhsfyx: '是',
558   - lxjl: '介绍肩颈调理活动,已确认可到店。'
559   - },
560   - {
561   - id: 'TI202603020003',
562   - time: '10:05',
563   - name: '张敏',
564   - mobile: '13800138002',
565   - dhsfyx: '否',
566   - lxjl: '电话无人接听,准备稍后再次联系。'
567   - },
568   - {
569   - id: 'TI202603020004',
570   - time: '10:40',
571   - name: '赵云',
572   - mobile: '13800138003',
573   - dhsfyx: '是',
574   - lxjl: '邀约身体舒缓护理体验,客户需与家人确认时间。'
575   - },
576   - {
577   - id: 'TI202603020005',
578   - time: '11:20',
579   - name: '刘芳',
580   - mobile: '13800138004',
581   - dhsfyx: '是',
582   - lxjl: '眼周护理体验回访,客户反馈效果不错。'
583   - },
584   - {
585   - id: 'TI202603020006',
586   - time: '13:15',
587   - name: '陈洁',
588   - mobile: '13800138005',
589   - dhsfyx: '是',
590   - lxjl: '面部护理加购邀约,客户考虑本周安排。'
591   - },
592   - {
593   - id: 'TI202603020007',
594   - time: '14:00',
595   - name: '周雪',
596   - mobile: '13800138006',
597   - dhsfyx: '否',
598   - lxjl: '电话为空号,准备核实客户信息。'
599   - },
600   - {
601   - id: 'TI202603020008',
602   - time: '15:10',
603   - name: '李楠',
604   - mobile: '13800138007',
605   - dhsfyx: '是',
606   - lxjl: '身体护理活动邀约,客户希望下班后再联系。'
607   - },
608   - {
609   - id: 'TI202603020009',
610   - time: '16:25',
611   - name: '何倩',
612   - mobile: '13800138008',
613   - dhsfyx: '是',
614   - lxjl: '生日关怀邀约,初步定周末到店。'
615   - },
616   - {
617   - id: 'TI202603020010',
618   - time: '17:40',
619   - name: '孙悦',
620   - mobile: '13800138009',
621   - dhsfyx: '是',
622   - lxjl: '眼周护理复查邀约,客户暂不方便到店。'
623   - }
624   - ],
  621 + todayInvite: [],
625 622 todayBooking: [
626 623 {
627 624 id: 'TB202603020001',
... ... @@ -770,7 +767,6 @@ export default {
770 767 { id: 'S004', name: '赵海燕', days: 60, phone: '186****0023', level: 'B', lastVisit: '2025-01-06', balance: '1,600' }
771 768 ],
772 769 tuokeDialogVisible: false,
773   - inviteDialogVisible: false,
774 770 bookingDialogVisible: false,
775 771 bookingPrefill: {
776 772 name: '',
... ... @@ -781,12 +777,18 @@ export default {
781 777 consumeDialogVisible: false,
782 778 refundDialogVisible: false,
783 779 tuokeListVisible: false,
784   - inviteListVisible: false,
785 780 bookingCalendarVisible: false,
786 781 billingListVisible: false,
787 782 consumeListVisible: false,
788 783 refundListVisible: false,
789   - goldTriangleDialogVisible: false
  784 + goldTriangleDialogVisible: false,
  785 + // 提醒中心侧栏状态(今日预约 / 生日提醒 / 沉睡提醒)
  786 + // reminderDefaultOpen 可作为默认展开/收起开关
  787 + reminderDefaultOpen: false,
  788 + reminderCenterOpen: false,
  789 + // 提醒中心侧栏:首屏渲染完成后再启用滑动动画
  790 + reminderFirstRenderDone: false,
  791 + currentReminderType: 'booking'
790 792 }
791 793 },
792 794 computed: {
... ... @@ -849,6 +851,39 @@ export default {
849 851 const now = new Date()
850 852 const next = new Date(now.getFullYear(), now.getMonth() + 1, 1)
851 853 return `${next.getFullYear()}${String(next.getMonth() + 1).padStart(2, '0')}`
  854 + },
  855 + // 提醒中心入口数据(用于窄栏徽标统计)
  856 + reminderEntries() {
  857 + const today = new Date()
  858 + const month = today.getMonth() + 1
  859 + const day = today.getDate()
  860 + const birthdayTodayCount = this.birthdayAllData.filter(m => m.month === month && m.day === day).length
  861 + return [
  862 + {
  863 + key: 'todo',
  864 + label: '我的待办',
  865 + count: this.todoList.length,
  866 + icon: 'el-icon-warning-outline'
  867 + },
  868 + {
  869 + key: 'booking',
  870 + label: '今日预约',
  871 + count: this.todayBooking.length,
  872 + icon: 'el-icon-date'
  873 + },
  874 + {
  875 + key: 'birthday',
  876 + label: '生日提醒',
  877 + count: birthdayTodayCount,
  878 + icon: 'el-icon-gift'
  879 + },
  880 + {
  881 + key: 'sleeping',
  882 + label: '沉睡提醒',
  883 + count: this.sleepingMembers.length,
  884 + icon: 'el-icon-moon'
  885 + }
  886 + ]
852 887 }
853 888 },
854 889 mounted() {
... ... @@ -866,6 +901,23 @@ export default {
866 901 }
867 902 fmt()
868 903 this.clockTimer = setInterval(fmt, 1000)
  904 +
  905 + // 提醒中心默认展开/收起与上次状态(可配置)
  906 + const savedOpen = localStorage.getItem('store_reminder_open')
  907 + const savedType = localStorage.getItem('store_reminder_type')
  908 + const validTypes = ['todo', 'booking', 'birthday', 'sleeping']
  909 + if (savedType && validTypes.includes(savedType)) {
  910 + this.currentReminderType = savedType
  911 + this.todayTab = savedType
  912 + }
  913 + if (savedOpen !== null) {
  914 + this.reminderCenterOpen = savedOpen === '1'
  915 + } else {
  916 + this.reminderCenterOpen = this.reminderDefaultOpen
  917 + }
  918 + this.$nextTick(() => {
  919 + this.reminderFirstRenderDone = true
  920 + })
869 921 },
870 922 beforeDestroy() {
871 923 document.removeEventListener('fullscreenchange', this.onFullscreenChange)
... ... @@ -874,9 +926,9 @@ export default {
874 926 methods: {
875 927 toggleFullscreen() {
876 928 if (!document.fullscreenElement) {
877   - document.documentElement.requestFullscreen().catch(() => {})
  929 + document.documentElement.requestFullscreen().catch(() => { })
878 930 } else {
879   - document.exitFullscreen().catch(() => {})
  931 + document.exitFullscreen().catch(() => { })
880 932 }
881 933 },
882 934 onFullscreenChange() {
... ... @@ -893,7 +945,7 @@ export default {
893 945 }).then(() => {
894 946 localStorage.removeItem('store_token')
895 947 this.$router.push('/login')
896   - }).catch(() => {})
  948 + }).catch(() => { })
897 949 } else if (cmd === 'profile') {
898 950 this.$message.info('个人信息功能开发中')
899 951 } else if (cmd === 'password') {
... ... @@ -905,10 +957,6 @@ export default {
905 957 this.tuokeDialogVisible = true
906 958 return
907 959 }
908   - if (module.key === 'invite') {
909   - this.inviteDialogVisible = true
910   - return
911   - }
912 960 if (module.key === 'booking') {
913 961 this.bookingPrefill = { name: '', phone: '' }
914 962 this.bookingDialogVisible = true
... ... @@ -1121,6 +1169,15 @@ export default {
1121 1169 }
1122 1170 this.memberDialogVisible = true
1123 1171 },
  1172 + handleMemberAction(payload) {
  1173 + if (!payload || !payload.type) return
  1174 + const { type } = payload
  1175 + if (type === 'invite') {
  1176 + // 添加沟通记录:仅打开沟通记录表单
  1177 + this.contactDialogVisible = true
  1178 + }
  1179 + // 其它类型(booking / billing / consume / refund)暂时保持原有入口按钮逻辑
  1180 + },
1124 1181 handleQuickBooking(item) {
1125 1182 this.bookingPrefill = {
1126 1183 name: item.name,
... ... @@ -1157,6 +1214,34 @@ export default {
1157 1214 handleSleepingCall(item) {
1158 1215 this.$message.info(`正在为 ${item.name} 创建邀约...`)
1159 1216 },
  1217 + handleCancelBooking(item) {
  1218 + this.$confirm(`确定要取消「${item.name}」在 ${item.date} ${item.timeRange} 的预约吗?`, '取消预约确认', {
  1219 + confirmButtonText: '确定',
  1220 + cancelButtonText: '再想想',
  1221 + type: 'warning'
  1222 + }).then(() => {
  1223 + this.$message.success('预约已标记为取消(示例)')
  1224 + }).catch(() => {})
  1225 + },
  1226 + handleConfirmConsume(item) {
  1227 + this.$confirm(`确认「${item.name}」本次预约已完成并产生消耗吗?`, '确认消耗', {
  1228 + confirmButtonText: '确定',
  1229 + cancelButtonText: '再想想',
  1230 + type: 'info'
  1231 + }).then(() => {
  1232 + this.$message.success('已标记为已消耗(示例)')
  1233 + }).catch(() => {})
  1234 + },
  1235 + handleTodoClick(item) {
  1236 + if (!item || !item.key) return
  1237 + if (item.key === 'goldTriangle') {
  1238 + this.goldTriangleDialogVisible = true
  1239 + return
  1240 + }
  1241 + if (item.key === 'storeTarget') {
  1242 + this.storeTargetDialogVisible = true
  1243 + }
  1244 + },
1160 1245 selectBirthdayDay(day) {
1161 1246 this.selectedBirthdayDay = this.selectedBirthdayDay === day ? null : day
1162 1247 },
... ... @@ -1212,7 +1297,6 @@ export default {
1212 1297 }
1213 1298 const map = {
1214 1299 tuoke: 'tuokeListVisible',
1215   - invite: 'inviteListVisible',
1216 1300 booking: 'bookingCalendarVisible',
1217 1301 order: 'billingListVisible',
1218 1302 consume: 'consumeListVisible',
... ... @@ -1220,6 +1304,29 @@ export default {
1220 1304 }
1221 1305 const key = map[module.key]
1222 1306 if (key) this[key] = true
  1307 + },
  1308 + // 提醒中心侧栏:入口点击
  1309 + handleReminderEntryClick(type) {
  1310 + if (this.reminderCenterOpen && this.currentReminderType === type) {
  1311 + this.reminderCenterOpen = false
  1312 + localStorage.setItem('store_reminder_open', '0')
  1313 + return
  1314 + }
  1315 + this.currentReminderType = type
  1316 + this.todayTab = type
  1317 + this.reminderCenterOpen = true
  1318 + localStorage.setItem('store_reminder_open', '1')
  1319 + localStorage.setItem('store_reminder_type', this.currentReminderType)
  1320 + },
  1321 + // 提醒中心侧栏:面板显隐
  1322 + toggleReminderCenter(open) {
  1323 + this.reminderCenterOpen = open
  1324 + localStorage.setItem('store_reminder_open', open ? '1' : '0')
  1325 + },
  1326 + // 提醒中心侧栏:Tab 切换时同步当前类型
  1327 + handleTodayTabClick(tab) {
  1328 + this.currentReminderType = tab.name
  1329 + localStorage.setItem('store_reminder_type', this.currentReminderType)
1223 1330 }
1224 1331 }
1225 1332 }
... ... @@ -1228,12 +1335,13 @@ export default {
1228 1335 <style lang="scss" scoped>
1229 1336 .dashboard-page {
1230 1337 height: 100%;
1231   - padding: 24px 28px;
  1338 + padding: 24px 20px 24px 28px;
1232 1339 background: radial-gradient(circle at 10% 20%, #eef2ff 0, #eef2ff 26%, #fdf2ff 60%, #fff7ed 100%);
1233 1340 box-sizing: border-box;
1234 1341 display: flex;
1235 1342 flex-direction: column;
1236 1343 overflow: hidden;
  1344 + position: relative;
1237 1345 }
1238 1346  
1239 1347 .top-bar-right {
... ... @@ -1242,38 +1350,8 @@ export default {
1242 1350 gap: 12px;
1243 1351 min-width: 0;
1244 1352 }
1245   -.reminder-inline {
1246   - display: flex;
1247   - align-items: center;
1248   - flex-shrink: 0;
1249   - padding: 6px 12px;
1250   - border-radius: 8px;
1251   - background: linear-gradient(135deg, #fffbeb, #fef3c7);
1252   - border: 1px solid #fde68a;
1253   - font-size: 13px;
1254   - color: #92400e;
1255   -}
1256   -.reminder-inline .reminder-icon {
1257   - font-size: 14px;
1258   - margin-right: 4px;
1259   - color: #f59e0b;
1260   -}
1261   -.reminder-inline .reminder-label {
1262   - font-weight: 600;
1263   - margin-right: 2px;
1264   -}
1265   -.reminder-inline .reminder-item {
1266   - cursor: pointer;
1267   - color: #2563eb;
1268   - font-weight: 500;
1269   - &:hover {
1270   - text-decoration: underline;
1271   - }
1272   -}
1273   -.reminder-inline .reminder-sep {
1274   - color: #92400e;
1275   - margin: 0 1px;
1276   -}
  1353 +
  1354 +/* 顶部我的待办样式已移动到右侧工具栏 */
1277 1355  
1278 1356 .top-bar {
1279 1357 display: grid;
... ... @@ -1289,6 +1367,36 @@ export default {
1289 1367 max-width: 420px;
1290 1368 }
1291 1369  
  1370 +.calendar-entry {
  1371 + flex-shrink: 0;
  1372 + display: inline-flex;
  1373 + align-items: center;
  1374 + gap: 6px;
  1375 + padding: 8px 12px;
  1376 + border-radius: 999px;
  1377 + background: linear-gradient(135deg, #e0f2fe, #ddd6fe);
  1378 + border: 1px solid rgba(129, 140, 248, 0.55);
  1379 + color: #1d4ed8;
  1380 + font-size: 13px;
  1381 + font-weight: 500;
  1382 + cursor: pointer;
  1383 + box-shadow: 0 4px 12px rgba(79, 70, 229, 0.28);
  1384 + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
  1385 +}
  1386 +
  1387 +.calendar-entry-icon {
  1388 + font-size: 16px;
  1389 +}
  1390 +
  1391 +.calendar-entry-text {
  1392 + white-space: nowrap;
  1393 +}
  1394 +
  1395 +.calendar-entry:hover {
  1396 + transform: translateY(-1px);
  1397 + box-shadow: 0 8px 20px rgba(79, 70, 229, 0.35);
  1398 +}
  1399 +
1292 1400 @media (max-width: 1024px) {
1293 1401 .top-bar-right {
1294 1402 flex-wrap: wrap;
... ... @@ -1307,12 +1415,16 @@ export default {
1307 1415 margin-top: 4px;
1308 1416 font-size: 14px;
1309 1417 color: #5b6b7a;
  1418 +
1310 1419 .schedule-link {
1311 1420 margin-left: 12px;
1312 1421 color: #6366f1;
1313 1422 cursor: pointer;
1314 1423 font-weight: 500;
1315   - &:hover { text-decoration: underline; }
  1424 +
  1425 + &:hover {
  1426 + text-decoration: underline;
  1427 + }
1316 1428 }
1317 1429 }
1318 1430 }
... ... @@ -1342,13 +1454,44 @@ export default {
1342 1454 font-weight: 600;
1343 1455 flex-shrink: 0;
1344 1456 }
1345   -.suggestion-level.level--aplusplus { background: linear-gradient(135deg, #ef4444, #dc2626); color: #fff; }
1346   -.suggestion-level.level--aplus { background: linear-gradient(135deg, #f59e0b, #d97706); color: #fff; }
1347   -.suggestion-level.level--a { background: rgba(245,158,11,0.12); color: #d97706; border: 1px solid rgba(245,158,11,0.25); }
1348   -.suggestion-level.level--b { background: rgba(34,197,94,0.12); color: #16a34a; border: 1px solid rgba(34,197,94,0.25); }
1349   -.suggestion-level.level--c { background: rgba(59,130,246,0.12); color: #2563eb; border: 1px solid rgba(59,130,246,0.25); }
1350   -.suggestion-level.level--d { background: #e5e7eb; color: #6b7280; }
1351   -.suggestion-level.level--default { background: #e5e7eb; color: #6b7280; }
  1457 +
  1458 +.suggestion-level.level--aplusplus {
  1459 + background: linear-gradient(135deg, #ef4444, #dc2626);
  1460 + color: #fff;
  1461 +}
  1462 +
  1463 +.suggestion-level.level--aplus {
  1464 + background: linear-gradient(135deg, #f59e0b, #d97706);
  1465 + color: #fff;
  1466 +}
  1467 +
  1468 +.suggestion-level.level--a {
  1469 + background: rgba(245, 158, 11, 0.12);
  1470 + color: #d97706;
  1471 + border: 1px solid rgba(245, 158, 11, 0.25);
  1472 +}
  1473 +
  1474 +.suggestion-level.level--b {
  1475 + background: rgba(34, 197, 94, 0.12);
  1476 + color: #16a34a;
  1477 + border: 1px solid rgba(34, 197, 94, 0.25);
  1478 +}
  1479 +
  1480 +.suggestion-level.level--c {
  1481 + background: rgba(59, 130, 246, 0.12);
  1482 + color: #2563eb;
  1483 + border: 1px solid rgba(59, 130, 246, 0.25);
  1484 +}
  1485 +
  1486 +.suggestion-level.level--d {
  1487 + background: #e5e7eb;
  1488 + color: #6b7280;
  1489 +}
  1490 +
  1491 +.suggestion-level.level--default {
  1492 + background: #e5e7eb;
  1493 + color: #6b7280;
  1494 +}
1352 1495  
1353 1496 .suggestion-meta {
1354 1497 display: flex;
... ... @@ -1357,11 +1500,13 @@ export default {
1357 1500 font-size: 12px;
1358 1501 color: #64748b;
1359 1502 }
  1503 +
1360 1504 .suggestion-meta span {
1361 1505 display: flex;
1362 1506 align-items: center;
1363 1507 gap: 4px;
1364 1508 }
  1509 +
1365 1510 .suggestion-meta i {
1366 1511 font-size: 12px;
1367 1512 color: #94a3b8;
... ... @@ -1394,6 +1539,7 @@ export default {
1394 1539  
1395 1540 .member-search-input {
1396 1541 width: 100%;
  1542 +
1397 1543 ::v-deep .el-input__inner {
1398 1544 border-radius: 999px;
1399 1545 padding-left: 40px;
... ... @@ -1449,6 +1595,7 @@ export default {
1449 1595 color: #64748b;
1450 1596 font-size: 16px;
1451 1597 transition: all 0.2s ease;
  1598 +
1452 1599 &:hover {
1453 1600 background: rgba(99, 102, 241, 0.08);
1454 1601 border-color: rgba(99, 102, 241, 0.25);
... ... @@ -1469,6 +1616,7 @@ export default {
1469 1616 border: 1px solid rgba(226, 232, 240, 0.8);
1470 1617 background: rgba(241, 245, 249, 0.9);
1471 1618 transition: all 0.2s ease;
  1619 +
1472 1620 &:hover {
1473 1621 background: rgba(99, 102, 241, 0.06);
1474 1622 border-color: rgba(99, 102, 241, 0.2);
... ... @@ -1520,15 +1668,20 @@ export default {
1520 1668 align-items: center;
1521 1669 gap: 8px;
1522 1670 line-height: 1.4;
  1671 +
1523 1672 &:hover {
1524 1673 background: rgba(99, 102, 241, 0.06);
1525 1674 color: #4f46e5;
1526 1675 }
1527   - i { font-size: 15px; }
  1676 +
  1677 + i {
  1678 + font-size: 15px;
  1679 + }
1528 1680 }
1529 1681  
1530 1682 ::v-deep .user-dropdown-menu .dropdown-item--danger {
1531 1683 color: #ef4444 !important;
  1684 +
1532 1685 &:hover {
1533 1686 background: rgba(239, 68, 68, 0.06) !important;
1534 1687 color: #dc2626 !important;
... ... @@ -1578,7 +1731,7 @@ export default {
1578 1731  
1579 1732 .layout-grid {
1580 1733 display: grid;
1581   - grid-template-columns: minmax(0, 2.2fr) minmax(0, 1.1fr);
  1734 + grid-template-columns: minmax(0, 1fr) auto;
1582 1735 gap: 20px;
1583 1736 flex: 1;
1584 1737 min-height: 0;
... ... @@ -1593,8 +1746,15 @@ export default {
1593 1746 overflow-y: auto;
1594 1747 scrollbar-width: thin;
1595 1748 scrollbar-color: rgba(148, 163, 184, 0.3) transparent;
1596   - &::-webkit-scrollbar { width: 4px; }
1597   - &::-webkit-scrollbar-thumb { background: rgba(148, 163, 184, 0.3); border-radius: 2px; }
  1749 +
  1750 + &::-webkit-scrollbar {
  1751 + width: 4px;
  1752 + }
  1753 +
  1754 + &::-webkit-scrollbar-thumb {
  1755 + background: rgba(148, 163, 184, 0.3);
  1756 + border-radius: 2px;
  1757 + }
1598 1758 }
1599 1759  
1600 1760 .modules-grid {
... ... @@ -1609,15 +1769,25 @@ export default {
1609 1769 .overview-card {
1610 1770 border-radius: 16px;
1611 1771 border: none;
  1772 + background: #ffffff;
1612 1773 box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
1613 1774 }
1614 1775 }
1615 1776  
  1777 +.overview-row-inner {
  1778 + display: flex;
  1779 +}
  1780 +
  1781 +.overview-row-inner > .el-col {
  1782 + flex: 1 1 0;
  1783 + max-width: none;
  1784 +}
  1785 +
1616 1786 .overview-meta {
1617 1787 display: flex;
1618 1788 align-items: center;
1619 1789 justify-content: space-between;
1620   - margin-bottom: 4px;
  1790 + margin-bottom: 6px;
1621 1791  
1622 1792 .label {
1623 1793 font-size: 14px;
... ... @@ -1634,15 +1804,91 @@ export default {
1634 1804 }
1635 1805  
1636 1806 .overview-value {
1637   - font-size: 26px;
  1807 + font-size: 18px;
1638 1808 font-weight: 600;
1639 1809 color: #111827;
  1810 + display: flex;
  1811 + align-items: baseline;
  1812 + gap: 4px;
1640 1813 }
1641 1814  
1642 1815 .overview-sub {
1643 1816 margin-top: 4px;
1644 1817 font-size: 13px;
1645 1818 color: #9ca3af;
  1819 + display: flex;
  1820 + align-items: baseline;
  1821 + gap: 4px;
  1822 +}
  1823 +
  1824 +.overview-card-inner {
  1825 + display: flex;
  1826 + flex-direction: column;
  1827 + row-gap: 6px;
  1828 +}
  1829 +
  1830 +.overview-main {
  1831 + display: flex;
  1832 + align-items: baseline;
  1833 + gap: 8px;
  1834 +}
  1835 +
  1836 +.overview-main-value {
  1837 + font-size: 26px;
  1838 + font-weight: 700;
  1839 + color: #0f172a;
  1840 +}
  1841 +
  1842 +.overview-secondary {
  1843 + display: flex;
  1844 + align-items: baseline;
  1845 + gap: 6px;
  1846 + font-size: 13px;
  1847 + color: #6b7280;
  1848 +}
  1849 +
  1850 +.overview-secondary-label {
  1851 + padding: 0 6px;
  1852 + border-radius: 999px;
  1853 + background: #f3f4f6;
  1854 + font-size: 11px;
  1855 +}
  1856 +
  1857 +.overview-secondary-value {
  1858 + font-size: 14px;
  1859 + font-weight: 500;
  1860 + color: #374151;
  1861 +}
  1862 +
  1863 +/* 顶部统计:不同指标的内部色彩区分(保持白底统一) */
  1864 +.overview-card--headCount {
  1865 + .overview-main-value {
  1866 + color: #1d4ed8;
  1867 + }
  1868 +}
  1869 +
  1870 +.overview-card--visitCount {
  1871 + .overview-main-value {
  1872 + color: #4f46e5;
  1873 + }
  1874 +}
  1875 +
  1876 +.overview-card--projectCount {
  1877 + .overview-main-value {
  1878 + color: #be185d;
  1879 + }
  1880 +}
  1881 +
  1882 +.overview-card--consumeAmount {
  1883 + .overview-main-value {
  1884 + color: #047857;
  1885 + }
  1886 +}
  1887 +
  1888 +.overview-card--refundAmount {
  1889 + .overview-main-value {
  1890 + color: #c2410c;
  1891 + }
1646 1892 }
1647 1893  
1648 1894 .modules-grid .module-card {
... ... @@ -1751,6 +1997,7 @@ export default {
1751 1997 box-shadow: 0 4px 10px rgba(37, 99, 235, 0.35);
1752 1998 cursor: pointer;
1753 1999 transition: background 0.15s, box-shadow 0.15s;
  2000 +
1754 2001 &:hover {
1755 2002 background: #1d4ed8;
1756 2003 box-shadow: 0 4px 12px rgba(37, 99, 235, 0.45);
... ... @@ -1763,6 +2010,7 @@ export default {
1763 2010 cursor: pointer;
1764 2011 font-weight: 500;
1765 2012 transition: color 0.15s;
  2013 +
1766 2014 &:hover {
1767 2015 color: #1d4ed8;
1768 2016 text-decoration: underline;
... ... @@ -1782,24 +2030,182 @@ export default {
1782 2030 }
1783 2031 }
1784 2032  
1785   -.right-column {
  2033 +/* 提醒中心侧栏 - 容器 */
  2034 +.reminder-center {
  2035 + display: flex;
  2036 + flex-direction: row-reverse;
  2037 + align-items: stretch;
  2038 +}
  2039 +
  2040 +/* 提醒中心侧栏 - 窄栏入口 */
  2041 +.reminder-center-toggle {
  2042 + width: 88px;
  2043 + background: #ffffff;
  2044 + border-radius: 18px;
  2045 + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.18);
1786 2046 display: flex;
1787 2047 flex-direction: column;
1788   - min-height: 0;
  2048 + align-items: center;
  2049 + justify-content: flex-start;
  2050 + padding: 10px 4px;
  2051 + gap: 6px;
  2052 + pointer-events: auto;
  2053 +}
  2054 +
  2055 +/* 我的待办入口样式复用提醒入口,无需额外样式 */
  2056 +
  2057 +.reminder-toggle-item {
  2058 + width: 100%;
  2059 + border-radius: 12px;
  2060 + padding: 8px 6px;
  2061 + display: flex;
  2062 + flex-direction: column;
  2063 + align-items: center;
  2064 + justify-content: flex-start;
  2065 + gap: 6px;
  2066 + cursor: pointer;
  2067 + transition: background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
  2068 +}
  2069 +
  2070 +.reminder-toggle-item .label-text {
  2071 + font-size: 12px;
  2072 + color: #4b5563;
  2073 + letter-spacing: 0.5px;
  2074 + white-space: nowrap;
  2075 + overflow: hidden;
  2076 + text-overflow: ellipsis;
  2077 + text-align: center;
  2078 + width: 100%;
  2079 +}
  2080 +
  2081 +.entry-icon-wrap {
  2082 + width: 40px;
  2083 + height: 40px;
  2084 + border-radius: 12px;
  2085 + display: flex;
  2086 + align-items: center;
  2087 + justify-content: center;
  2088 + background: linear-gradient(135deg, #e5edff, #e0f2fe);
  2089 + box-shadow: 0 6px 14px rgba(15, 23, 42, 0.2);
  2090 + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
  2091 +}
  2092 +
  2093 +.entry-icon-wrap .entry-icon {
  2094 + font-size: 18px;
  2095 + color: #ffffff;
  2096 +}
  2097 +
  2098 +/* 提醒中心入口样式:不同类型入口渐变色 */
  2099 +.entry-icon-wrap--invite {
  2100 + background: linear-gradient(135deg, #0ea5e9, #22c55e);
  2101 +}
  2102 +
  2103 +.entry-icon-wrap--booking {
  2104 + background: linear-gradient(135deg, #f97316, #facc15);
  2105 +}
  2106 +
  2107 +.entry-icon-wrap--birthday {
  2108 + background: linear-gradient(135deg, #ec4899, #8b5cf6);
  2109 +}
  2110 +
  2111 +.entry-icon-wrap--sleeping {
  2112 + background: linear-gradient(135deg, #6366f1, #0ea5e9);
1789 2113 }
1790 2114  
1791   -.right-column .today-card {
  2115 +/* 我的待办图标底色:橙粉渐变,区分其他入口 */
  2116 +.entry-icon-wrap--todo {
  2117 + background: linear-gradient(135deg, #f97316, #ec4899);
  2118 +}
  2119 +
  2120 +.reminder-badge {
  2121 + display: inline-block;
  2122 +}
  2123 +
  2124 +.reminder-toggle-item .reminder-badge {
  2125 + /* 提醒中心入口样式:数字角标位置微调贴近图标右上角 */
  2126 + ::v-deep .el-badge__content {
  2127 + background-color: #ef4444;
  2128 + border: none;
  2129 + box-shadow: 0 0 0 1px #fee2e2;
  2130 + right: 2px;
  2131 + top: 2px;
  2132 + padding: 0 4px;
  2133 + height: 16px;
  2134 + line-height: 16px;
  2135 + font-size: 11px;
  2136 + }
  2137 +}
  2138 +
  2139 +.entry-icon {
  2140 + flex-shrink: 0;
  2141 +}
  2142 +
  2143 +.reminder-toggle-item.is-active {
  2144 + background: linear-gradient(180deg, rgba(59, 130, 246, 0.06), rgba(59, 130, 246, 0.10));
  2145 +}
  2146 +
  2147 +.reminder-toggle-item.is-active .label-text {
  2148 + color: #1d4ed8;
  2149 + font-weight: 600;
  2150 +}
  2151 +
  2152 +.reminder-toggle-item.is-active .entry-icon-wrap {
  2153 + /* 点击后仅轻微高亮,不再增加额外阴影 */
  2154 + box-shadow: 0 6px 14px rgba(37, 99, 235, 0.35);
  2155 +}
  2156 +
  2157 +.reminder-toggle-item.todo-entry {
  2158 + /* 未选中时完全使用通用样式,不额外高亮 */
  2159 +}
  2160 +
  2161 +.reminder-toggle-item.todo-entry.is-active {
  2162 + background: linear-gradient(180deg, rgba(253, 230, 138, 1), rgba(252, 165, 165, 1));
  2163 +}
  2164 +
  2165 +.reminder-toggle-item.todo-entry .label-text {
  2166 + color: #92400e;
  2167 + font-weight: 600;
  2168 +}
  2169 +
  2170 +.reminder-toggle-close {
  2171 + margin-top: 4px;
  2172 + width: 26px;
  2173 + height: 26px;
  2174 + border-radius: 999px;
  2175 + background: rgba(15, 23, 42, 0.04);
  2176 + display: flex;
  2177 + align-items: center;
  2178 + justify-content: center;
  2179 + cursor: pointer;
  2180 + color: #6b7280;
  2181 + transition: background 0.15s ease, color 0.15s ease;
  2182 +}
  2183 +
  2184 +.reminder-toggle-close:hover {
  2185 + background: rgba(59, 130, 246, 0.16);
  2186 + color: #1d4ed8;
  2187 +}
  2188 +
  2189 +/* 提醒中心侧栏 - 主体卡片(右侧有间距滑出) */
  2190 +.reminder-center-panel {
  2191 + width: 400px;
  2192 + margin-right: 20px;
  2193 + pointer-events: auto;
  2194 + display: flex;
  2195 +}
  2196 +
  2197 +/* 提醒面板布局:独立四角圆角卡片 */
  2198 +.reminder-center-panel .today-card {
1792 2199 border-radius: 18px;
1793 2200 border: none;
1794   - box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
  2201 + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.18);
1795 2202 flex: 1;
1796 2203 display: flex;
1797 2204 flex-direction: column;
1798 2205 min-height: 0;
1799 2206 }
1800 2207  
1801   -/* el-card 内部撑满 */
1802   -.right-column .today-card ::v-deep .el-card__body {
  2208 +.reminder-center-panel .today-card ::v-deep .el-card__body {
1803 2209 flex: 1;
1804 2210 min-height: 0;
1805 2211 display: flex;
... ... @@ -1807,34 +2213,56 @@ export default {
1807 2213 overflow: hidden;
1808 2214 }
1809 2215  
1810   -/* el-tabs 撑满 */
1811   -.right-column .today-card ::v-deep .el-tabs {
  2216 +.reminder-center-panel .today-card ::v-deep .el-tabs {
1812 2217 flex: 1;
1813 2218 min-height: 0;
1814 2219 display: flex;
1815 2220 flex-direction: column;
1816 2221 }
1817 2222  
1818   -.right-column .today-card ::v-deep .el-tabs__content {
  2223 +.reminder-center-panel .today-card ::v-deep .el-tabs__content {
1819 2224 flex: 1;
1820 2225 min-height: 0;
1821 2226 overflow: hidden;
1822 2227 }
1823 2228  
1824   -.right-column .today-card ::v-deep .el-tab-pane {
  2229 +.reminder-center-panel .today-card ::v-deep .el-tab-pane {
1825 2230 height: 100%;
1826 2231 display: flex;
1827 2232 flex-direction: column;
1828 2233 }
1829 2234  
  2235 +/* 提醒中心侧栏 - 进出场动画 */
  2236 +.reminder-center-slide-enter-active,
  2237 +.reminder-center-slide-leave-active {
  2238 + transition: transform 0.25s ease;
  2239 +}
  2240 +
  2241 +.reminder-center-slide-enter,
  2242 +.reminder-center-slide-leave-to {
  2243 + transform: translateX(100%);
  2244 +}
  2245 +
  2246 +.reminder-center-slide-enter-to,
  2247 +.reminder-center-slide-leave {
  2248 + transform: translateX(0);
  2249 +}
  2250 +
1830 2251 .timeline-list {
1831 2252 flex: 1;
1832 2253 min-height: 0;
1833 2254 overflow-y: auto;
1834 2255 scrollbar-width: thin;
1835 2256 scrollbar-color: rgba(148, 163, 184, 0.3) transparent;
1836   - &::-webkit-scrollbar { width: 4px; }
1837   - &::-webkit-scrollbar-thumb { background: rgba(148, 163, 184, 0.3); border-radius: 2px; }
  2257 +
  2258 + &::-webkit-scrollbar {
  2259 + width: 4px;
  2260 + }
  2261 +
  2262 + &::-webkit-scrollbar-thumb {
  2263 + background: rgba(148, 163, 184, 0.3);
  2264 + border-radius: 2px;
  2265 + }
1838 2266 }
1839 2267  
1840 2268 .timeline-item {
... ... @@ -1859,14 +2287,18 @@ export default {
1859 2287 display: flex;
1860 2288 align-items: center;
1861 2289 gap: 6px;
  2290 + white-space: nowrap;
1862 2291  
1863 2292 .name {
1864 2293 color: #111827;
1865 2294 font-weight: 600;
  2295 + overflow: hidden;
  2296 + text-overflow: ellipsis;
1866 2297 }
1867 2298  
1868 2299 .mobile {
1869 2300 color: #6b7280;
  2301 + flex-shrink: 0;
1870 2302 }
1871 2303 }
1872 2304  
... ... @@ -1880,10 +2312,33 @@ export default {
1880 2312 .project {
1881 2313 color: #6b7280;
1882 2314 flex: 1;
  2315 + overflow: hidden;
  2316 + text-overflow: ellipsis;
  2317 + white-space: nowrap;
1883 2318 }
1884 2319 }
1885 2320 }
1886 2321  
  2322 +.booking-actions {
  2323 + display: flex;
  2324 + align-items: center;
  2325 + gap: 4px;
  2326 + flex-shrink: 0;
  2327 +}
  2328 +
  2329 +.booking-action-btn {
  2330 + padding: 0 4px;
  2331 + font-size: 12px;
  2332 +}
  2333 +
  2334 +.booking-action-btn--cancel {
  2335 + color: #f97316;
  2336 +}
  2337 +
  2338 +.booking-action-btn--confirm {
  2339 + color: #16a34a;
  2340 +}
  2341 +
1887 2342 .quick-book-btn {
1888 2343 flex-shrink: 0;
1889 2344 border: none;
... ... @@ -1945,7 +2400,7 @@ export default {
1945 2400 flex: 1;
1946 2401 display: flex;
1947 2402 align-items: center;
1948   - justify-content: center;
  2403 + justify-content: flex-start;
1949 2404 font-size: 13px;
1950 2405 color: #9ca3af;
1951 2406 }
... ... @@ -2070,7 +2525,11 @@ export default {
2070 2525 color: #64748b;
2071 2526 font-size: 13px;
2072 2527 transition: all 0.2s;
2073   - &:hover { background: rgba(99, 102, 241, 0.08); color: #4f46e5; }
  2528 +
  2529 + &:hover {
  2530 + background: rgba(99, 102, 241, 0.08);
  2531 + color: #4f46e5;
  2532 + }
2074 2533 }
2075 2534  
2076 2535 .cal-weekdays {
... ... @@ -2103,9 +2562,19 @@ export default {
2103 2562 align-items: center;
2104 2563 padding: 3px 2px;
2105 2564 transition: background 0.15s;
2106   - &--empty { background: transparent; }
2107   - &:not(.cal-cell--empty) { cursor: pointer; }
2108   - &--has-birthday { background: rgba(99, 102, 241, 0.04); }
  2565 +
  2566 + &--empty {
  2567 + background: transparent;
  2568 + }
  2569 +
  2570 + &:not(.cal-cell--empty) {
  2571 + cursor: pointer;
  2572 + }
  2573 +
  2574 + &--has-birthday {
  2575 + background: rgba(99, 102, 241, 0.04);
  2576 + }
  2577 +
2109 2578 &--selected {
2110 2579 background: rgba(99, 102, 241, 0.15);
2111 2580 box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.3);
... ... @@ -2147,7 +2616,10 @@ export default {
2147 2616 color: #6366f1;
2148 2617 cursor: pointer;
2149 2618 font-weight: 500;
2150   - &:hover { text-decoration: underline; }
  2619 +
  2620 + &:hover {
  2621 + text-decoration: underline;
  2622 + }
2151 2623 }
2152 2624  
2153 2625 .cal-member-list {
... ... @@ -2161,7 +2633,10 @@ export default {
2161 2633 gap: 10px;
2162 2634 padding: 8px 0;
2163 2635 border-bottom: 1px solid #f1f5f9;
2164   - &:last-child { border-bottom: none; }
  2636 +
  2637 + &:last-child {
  2638 + border-bottom: none;
  2639 + }
2165 2640 }
2166 2641  
2167 2642 .sleeping-item {
... ... @@ -2170,7 +2645,10 @@ export default {
2170 2645 gap: 10px;
2171 2646 padding: 10px 0;
2172 2647 border-bottom: 1px solid #f1f5f9;
2173   - &:last-child { border-bottom: none; }
  2648 +
  2649 + &:last-child {
  2650 + border-bottom: none;
  2651 + }
2174 2652 }
2175 2653  
2176 2654 .birthday-day-badge {
... ... @@ -2202,6 +2680,9 @@ export default {
2202 2680 display: flex;
2203 2681 align-items: center;
2204 2682 gap: 6px;
  2683 + overflow: hidden;
  2684 + text-overflow: ellipsis;
  2685 + white-space: nowrap;
2205 2686 }
2206 2687  
2207 2688 .birthday-level-tag {
... ... @@ -2217,6 +2698,9 @@ export default {
2217 2698 font-size: 12px;
2218 2699 color: #94a3b8;
2219 2700 margin-top: 2px;
  2701 + overflow: hidden;
  2702 + text-overflow: ellipsis;
  2703 + white-space: nowrap;
2220 2704 }
2221 2705  
2222 2706 .birthday-amount,
... ... @@ -2224,6 +2708,9 @@ export default {
2224 2708 font-size: 11px;
2225 2709 color: #64748b;
2226 2710 margin-top: 1px;
  2711 + overflow: hidden;
  2712 + text-overflow: ellipsis;
  2713 + white-space: nowrap;
2227 2714 }
2228 2715  
2229 2716 .sleeping-days {
... ... @@ -2254,7 +2741,7 @@ export default {
2254 2741  
2255 2742 @media (max-width: 1024px) {
2256 2743 .layout-grid {
2257   - grid-template-columns: minmax(0, 1.7fr) minmax(0, 1.1fr);
  2744 + grid-template-columns: minmax(0, 1fr);
2258 2745 }
2259 2746 }
2260 2747  
... ... @@ -2279,6 +2766,7 @@ export default {
2279 2766 .member-search-dropdown {
2280 2767 min-width: 420px !important;
2281 2768 max-height: 360px;
  2769 +
2282 2770 .el-autocomplete-suggestion__list li {
2283 2771 padding: 10px 14px;
2284 2772 line-height: 1.4;
... ... @@ -2289,8 +2777,8 @@ export default {
2289 2777 .store-switch-dialog {
2290 2778 border-radius: 20px !important;
2291 2779 padding: 0 !important;
2292   - background: radial-gradient(circle at 0 0, rgba(255,255,255,0.96) 0, rgba(248,250,252,0.98) 40%, rgba(241,245,249,0.98) 100%) !important;
2293   - box-shadow: 0 24px 48px rgba(15,23,42,0.18), 0 0 0 1px rgba(255,255,255,0.9) !important;
  2780 + background: radial-gradient(circle at 0 0, rgba(255, 255, 255, 0.96) 0, rgba(248, 250, 252, 0.98) 40%, rgba(241, 245, 249, 0.98) 100%) !important;
  2781 + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18), 0 0 0 1px rgba(255, 255, 255, 0.9) !important;
2294 2782 backdrop-filter: blur(22px);
2295 2783 -webkit-backdrop-filter: blur(22px);
2296 2784 }
... ... @@ -2317,7 +2805,7 @@ export default {
2317 2805 margin: 18px 22px 0;
2318 2806 padding: 10px 14px;
2319 2807 border-radius: 14px;
2320   - background: rgba(219,234,254,0.96);
  2808 + background: rgba(219, 234, 254, 0.96);
2321 2809 }
2322 2810  
2323 2811 .store-switch-title {
... ... @@ -2336,8 +2824,9 @@ export default {
2336 2824 border-radius: 999px;
2337 2825 color: #64748b;
2338 2826 transition: all 0.15s;
  2827 +
2339 2828 &:hover {
2340   - background: rgba(0,0,0,0.06);
  2829 + background: rgba(0, 0, 0, 0.06);
2341 2830 color: #0f172a;
2342 2831 }
2343 2832 }
... ... @@ -2359,11 +2848,16 @@ export default {
2359 2848 transition: all 0.2s;
2360 2849 border: 1px solid transparent;
2361 2850 margin-bottom: 8px;
2362   - &:last-child { margin-bottom: 0; }
  2851 +
  2852 + &:last-child {
  2853 + margin-bottom: 0;
  2854 + }
  2855 +
2363 2856 &:hover {
2364 2857 background: rgba(99, 102, 241, 0.06);
2365 2858 border-color: rgba(99, 102, 241, 0.2);
2366 2859 }
  2860 +
2367 2861 &--active {
2368 2862 background: rgba(219, 234, 254, 0.5);
2369 2863 border-color: rgba(59, 130, 246, 0.3);
... ... @@ -2421,6 +2915,7 @@ export default {
2421 2915 align-items: flex-start;
2422 2916 gap: 4px;
2423 2917 line-height: 1.5;
  2918 +
2424 2919 i {
2425 2920 font-size: 12px;
2426 2921 color: #94a3b8;
... ...