release: freeze 2026-10-15 validated release baseline
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
This commit is contained in:
@@ -1,26 +1,52 @@
|
||||
<template>
|
||||
<div class="admin-scene" :class="{ 'sidebar-collapsed': isCollapse }">
|
||||
<header class="scene-header" aria-label="善缘丹阳公益服务品牌信息">
|
||||
<header class="scene-header" aria-label="爱在丹阳公益服务品牌信息">
|
||||
<!-- 背景装饰层:粉渐变兜底 + 丹阳剪影底图 + 爱心点缀 -->
|
||||
<div class="header-bg" aria-hidden="true"></div>
|
||||
<div class="header-hearts" aria-hidden="true">
|
||||
<span>♡</span><span>♡</span><span>♡</span><span>♡</span>
|
||||
</div>
|
||||
|
||||
<div class="scene-brand">
|
||||
<img :src="BRAND_LOGO" alt="善缘丹阳" />
|
||||
<div class="brand-logo-wrap">
|
||||
<img :src="BRAND_LOGO" alt="爱在丹阳" />
|
||||
<span class="brand-dot" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="scene-brand-copy">
|
||||
<strong>善缘丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
<div class="brand-name-row">
|
||||
<strong>爱在丹阳</strong>
|
||||
<em class="brand-badge">公益运营</em>
|
||||
</div>
|
||||
<span class="brand-tagline">公益婚恋服务平台</span>
|
||||
<span class="brand-ornament" aria-hidden="true">❖ ● ❖ ● ❖</span>
|
||||
</div>
|
||||
<div class="scene-title">
|
||||
<h1>管理端(运营后台)</h1>
|
||||
<p>用专业的服务,帮助更多人遇见对的人</p>
|
||||
<p class="title-sub">
|
||||
<span>公益婚恋服务平台</span>
|
||||
<span class="sep">·</span>
|
||||
<span>用爱连接美好姻缘</span>
|
||||
<span class="sep">·</span>
|
||||
<span>让更多人在丹阳遇见幸福</span>
|
||||
</p>
|
||||
<p class="title-meta">
|
||||
<span class="meta-chip" title="当前权限范围内今日新增、分配、跟进或完成的红娘服务单,按北京时间去重">今日服务 {{ todayStats?.total ?? '—' }} 单</span>
|
||||
<span class="meta-chip" title="当前权限范围内尚未完成的红娘服务单">待处理 {{ todayStats?.pending ?? '—' }} 件</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scene-slogan" aria-hidden="true">
|
||||
<span>善缘相牵</span>
|
||||
<strong>爱在丹阳</strong>
|
||||
<span class="slogan-top">爱在丹阳</span>
|
||||
<strong class="slogan-main">原来是你</strong>
|
||||
<em class="slogan-en">Love In Danyang</em>
|
||||
</div>
|
||||
|
||||
<div class="scene-benefits" aria-label="平台服务原则">
|
||||
<span><el-icon><StarFilled /></el-icon><b>公益为本</b><small>不以营利为目的</small></span>
|
||||
<span><el-icon><Lock /></el-icon><b>真实可靠</b><small>严格审核 多重保障</small></span>
|
||||
<span><el-icon><LocationFilled /></el-icon><b>本地服务</b><small>深耕丹阳 服务家乡</small></span>
|
||||
<span><el-icon><Opportunity /></el-icon><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
|
||||
<span><i class="benefit-icon"><el-icon><StarFilled /></el-icon></i><b>公益为本</b><small>不以营利为目的</small></span>
|
||||
<span><i class="benefit-icon"><el-icon><Lock /></el-icon></i><b>真实可靠</b><small>严格审核·多重保障</small></span>
|
||||
<span><i class="benefit-icon"><el-icon><LocationFilled /></el-icon></i><b>本地服务</b><small>深耕丹阳·服务家乡</small></span>
|
||||
<span><i class="benefit-icon"><el-icon><Opportunity /></el-icon></i><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -29,7 +55,7 @@
|
||||
<div class="brand" :class="{ compact: isCollapse }">
|
||||
<img class="brand-mark" :src="BRAND_LOGO" alt="" />
|
||||
<div v-if="!isCollapse" class="brand-copy">
|
||||
<strong>善缘丹阳</strong>
|
||||
<strong>爱在丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,14 +147,16 @@
|
||||
</el-container>
|
||||
|
||||
<footer class="scene-footer">
|
||||
<strong>善缘丹阳</strong><span>公益婚恋服务平台</span>
|
||||
<strong>爱在丹阳</strong><span>公益婚恋服务平台</span>
|
||||
<small>真实 · 真诚 · 公益 · 幸福</small>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
import { summarizeMatchmakerServices } from '@/services/dashboard'
|
||||
import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -199,6 +227,25 @@ const isCollapse = ref(false)
|
||||
const globalSearch = ref('')
|
||||
const notificationCount = ref(0)
|
||||
|
||||
const todayStats = ref<{ total: number; pending: number } | null>(null)
|
||||
|
||||
watch(() => route.fullPath, async () => {
|
||||
todayStats.value = null
|
||||
const perms = authStore.permissions
|
||||
if (authStore.token === 'ui-preview-only') return
|
||||
try {
|
||||
if (perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')) {
|
||||
const response: any = await get('/operator/matchmaker/management/services')
|
||||
if (response.success && Array.isArray(response.data)) todayStats.value = summarizeMatchmakerServices(response.data)
|
||||
} else if (perms.includes('matchmaker.view')) {
|
||||
const response: any = await get('/operator/matchmaker/dashboard')
|
||||
if (response.success) todayStats.value = { total: response.data.todayCount, pending: response.data.pendingCount + response.data.inProgressCount }
|
||||
}
|
||||
} catch {
|
||||
todayStats.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
if (route.path === '/users/detail') return '/users'
|
||||
return route.path
|
||||
@@ -258,137 +305,307 @@ function handleLogout() {
|
||||
<style scoped>
|
||||
.admin-scene {
|
||||
min-width: 1100px;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 18px 34px 14px;
|
||||
overflow: hidden;
|
||||
/* 背景图是 public/ 下的运行时资源,必须带 BASE_URL 前缀:
|
||||
写死品牌目录的绝对路径,在产物部署于 /admin/ 子路径时会 404(页面背景变纯色)。
|
||||
用 v-bind 把常量注入 CSS,避免在样式里各写一遍拼接。 */
|
||||
/* 先给纯色兜底,避免图未加载完成时闪白 */
|
||||
background: var(--admin-coral-lighter) center / cover fixed no-repeat;
|
||||
background-image: url(v-bind('BRAND_BACKDROP'));
|
||||
/* 外层:纯色粉渐变兜底,再叠加背景图。
|
||||
原实现把 background 和 background-image 拆两行在某些浏览器会覆盖 —— 这里统一写一条,
|
||||
并显式声明 position/size/repeat/attachment,确保 cover 全区域。 */
|
||||
background:
|
||||
linear-gradient(180deg, var(--admin-scene-bg-start) 0%, var(--admin-scene-bg-mid) 40%, var(--admin-scene-bg-end) 100%),
|
||||
url(v-bind('BRAND_BACKDROP')) center / cover fixed no-repeat;
|
||||
}
|
||||
|
||||
.scene-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
max-width: var(--admin-shell-max-width);
|
||||
height: 188px;
|
||||
margin: 0 auto;
|
||||
grid-template-columns: minmax(640px, 1fr) auto;
|
||||
grid-template-rows: 112px 76px;
|
||||
/* 100vw 撑满浏览器:用负 margin 抵消 admin-scene 的 padding */
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
height: 105px;
|
||||
margin: -18px -34px 12px;
|
||||
padding: 0 34px;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: 1fr 36px;
|
||||
grid-template-areas:
|
||||
"brand title slogan"
|
||||
"benefits benefits benefits";
|
||||
align-items: center;
|
||||
column-gap: var(--space-8);
|
||||
column-gap: 20px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ===== 背景装饰层 ===== */
|
||||
.header-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(135deg, var(--admin-scene-bg-start) 0%, var(--admin-scene-bg-mid) 55%, var(--admin-scene-bg-end) 100%),
|
||||
url(v-bind('BRAND_BACKDROP')) center / cover no-repeat;
|
||||
z-index: 0;
|
||||
}
|
||||
/* 左上角柔光晕 */
|
||||
.header-bg::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60px;
|
||||
left: -40px;
|
||||
width: 300px;
|
||||
height: 180px;
|
||||
background: radial-gradient(ellipse at center, var(--admin-scene-glow) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 右下角柔光晕 */
|
||||
.header-bg::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 40px;
|
||||
bottom: -30px;
|
||||
width: 280px;
|
||||
height: 140px;
|
||||
background: radial-gradient(ellipse at center, var(--admin-scene-glow-soft) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 爱心点缀 */
|
||||
.header-hearts {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 34px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: 16px;
|
||||
color: var(--admin-coral-border);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.header-hearts span {
|
||||
animation: heartbeat 3s ease-in-out infinite;
|
||||
}
|
||||
.header-hearts span:nth-child(2) { animation-delay: .4s; }
|
||||
.header-hearts span:nth-child(3) { animation-delay: .8s; }
|
||||
.header-hearts span:nth-child(4) { animation-delay: 1.2s; }
|
||||
@keyframes heartbeat {
|
||||
0%, 100% { transform: scale(1); opacity: .5; }
|
||||
50% { transform: scale(1.25); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ===== 所有 header 内容浮在背景层之上 ===== */
|
||||
.scene-brand,
|
||||
.scene-slogan,
|
||||
.scene-benefits {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ===== 品牌区 ===== */
|
||||
.scene-brand,
|
||||
.scene-benefits,
|
||||
.scene-benefits span,
|
||||
.scene-brand-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scene-brand > img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
.scene-brand {
|
||||
grid-area: brand;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scene-slogan {
|
||||
/* Logo + 装饰圆点 */
|
||||
.brand-logo-wrap {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.brand-logo-wrap img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
border-radius: 50%;
|
||||
clip-path: circle(50%);
|
||||
filter: drop-shadow(0 2px 6px rgba(244,95,104,0.25));
|
||||
}
|
||||
.brand-dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--gradient-brand-solid);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(200, 74, 83, 0.45);
|
||||
}
|
||||
|
||||
/* 品牌名 + 徽章 — tagline/ornament 在 105px 高度里塞不下,隐藏 */
|
||||
.scene-brand-copy {
|
||||
min-width: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
color: var(--admin-coral-strong);
|
||||
gap: 8px;
|
||||
}
|
||||
.brand-name-row {
|
||||
display: flex;
|
||||
padding-right: var(--space-6);
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.brand-name-row strong {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 2px;
|
||||
background: var(--gradient-brand-text);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: none;
|
||||
}
|
||||
.brand-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
color: var(--color-text-inverse);
|
||||
background: var(--gradient-brand-solid);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 6px rgba(200, 74, 83, 0.3);
|
||||
}
|
||||
.brand-tagline,
|
||||
.brand-ornament {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 标题区 */
|
||||
.scene-title {
|
||||
min-width: 0;
|
||||
margin-left: 20px;
|
||||
padding-left: 16px;
|
||||
border-left: 2px solid var(--admin-scene-divider);
|
||||
}
|
||||
.scene-title h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
letter-spacing: 1px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.title-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
.title-sub .sep {
|
||||
margin: 0 5px;
|
||||
color: var(--admin-coral-solid);
|
||||
font-weight: 700;
|
||||
}
|
||||
.title-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
height: 18px;
|
||||
color: var(--admin-coral-strong);
|
||||
background: var(--admin-scene-surface);
|
||||
border: 1px solid var(--admin-coral-border);
|
||||
border-radius: 9px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* ===== 书法 slogan ===== */
|
||||
.scene-slogan {
|
||||
grid-area: slogan;
|
||||
display: flex;
|
||||
padding-right: 0;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
color: var(--admin-coral-strong);
|
||||
font-family: var(--font-family-brand);
|
||||
line-height: 1.05;
|
||||
transform: rotate(-7deg);
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.scene-slogan span {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.scene-slogan strong {
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.scene-brand-copy {
|
||||
min-width: 172px;
|
||||
margin-left: 10px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
.slogan-top {
|
||||
font-size: 12px;
|
||||
letter-spacing: 6px;
|
||||
color: var(--admin-coral-strong);
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.scene-brand-copy strong {
|
||||
font-size: var(--font-size-4xl);
|
||||
line-height: 1.15;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.scene-brand-copy span {
|
||||
margin-top: 5px;
|
||||
font-size: var(--font-size-xs);
|
||||
.slogan-main {
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 4px;
|
||||
background: var(--gradient-brand-text);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.slogan-en {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scene-title {
|
||||
min-width: 370px;
|
||||
margin-left: 28px;
|
||||
padding-left: 28px;
|
||||
border-left: 1px solid var(--admin-coral-border);
|
||||
}
|
||||
|
||||
.scene-title h1 {
|
||||
font-size: clamp(var(--font-size-3xl), 2.2vw, var(--font-size-display));
|
||||
line-height: 1.1;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.scene-title p {
|
||||
margin-top: 9px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ===== 公益原则行 ===== */
|
||||
.scene-benefits {
|
||||
grid-column: 1 / -1;
|
||||
align-self: start;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(38px, 5vw, 92px);
|
||||
padding: var(--space-1) 0 0 50px;
|
||||
grid-area: benefits;
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: clamp(10px, 1.6vw, 28px);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scene-benefits span {
|
||||
display: grid;
|
||||
grid-template-columns: 36px auto;
|
||||
column-gap: var(--space-2);
|
||||
/* 13px 文字压在摄影背景图上,必须用 -strong 档(5.13:1),基础色只有 3.14:1 */
|
||||
color: var(--admin-coral-strong);
|
||||
}
|
||||
|
||||
.scene-benefits .el-icon {
|
||||
grid-row: 1 / 3;
|
||||
font-size: var(--font-size-4xl);
|
||||
}
|
||||
|
||||
.scene-benefits b {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.scene-benefits small {
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 14px 4px 8px;
|
||||
white-space: nowrap;
|
||||
background: var(--admin-scene-surface);
|
||||
border: 1px solid var(--admin-coral-border);
|
||||
border-radius: 18px;
|
||||
color: var(--admin-coral-strong);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 1px 4px rgba(200, 74, 83, 0.06);
|
||||
}
|
||||
.scene-benefits b {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.scene-benefits small {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
width: min(var(--admin-shell-max-width), 100%);
|
||||
height: calc(100vh - 238px);
|
||||
min-height: 560px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
background: var(--admin-shell-bg);
|
||||
@@ -427,6 +644,8 @@ function handleLogout() {
|
||||
height: 42px;
|
||||
flex: none;
|
||||
object-fit: contain;
|
||||
border-radius: 50%;
|
||||
clip-path: circle(50%);
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
@@ -629,10 +848,15 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
background: var(--color-bg-page);
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge 旧版 */
|
||||
/* 暖色调背景,与整体品牌粉调一致;冷灰蓝 --color-bg-page 仅用于通用组件 */
|
||||
background: var(--admin-scene-bg-end);
|
||||
}
|
||||
.admin-content::-webkit-scrollbar { display: none; } /* Chrome/Safari/Edge */
|
||||
|
||||
.scene-footer {
|
||||
display: flex;
|
||||
@@ -656,62 +880,49 @@ function handleLogout() {
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1450px), (max-height: 820px) {
|
||||
@media (max-width: 1280px), (max-height: 780px) {
|
||||
.admin-scene {
|
||||
padding: 12px 24px 10px;
|
||||
}
|
||||
|
||||
.scene-header {
|
||||
display: grid;
|
||||
height: 120px;
|
||||
grid-template-columns: minmax(520px, 1fr) auto;
|
||||
grid-template-rows: 66px 54px;
|
||||
column-gap: 18px;
|
||||
height: 160px;
|
||||
grid-template-columns: minmax(480px, 1fr) auto;
|
||||
grid-template-rows: 92px 64px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.scene-slogan {
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
.brand-logo-wrap { width: 64px; height: 64px; }
|
||||
.brand-logo-wrap img { width: 56px; height: 56px; }
|
||||
.brand-dot { display: none; }
|
||||
|
||||
.scene-slogan span {
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.scene-slogan strong {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
.scene-brand > img {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.scene-brand-copy strong {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
.brand-name-row strong { font-size: 30px; letter-spacing: 3px; }
|
||||
.brand-badge { display: none; }
|
||||
.brand-tagline { font-size: 12px; letter-spacing: 2px; }
|
||||
.brand-ornament { display: none; }
|
||||
|
||||
.scene-title {
|
||||
min-width: 310px;
|
||||
margin-left: 18px;
|
||||
padding-left: 18px;
|
||||
min-width: 280px;
|
||||
margin-left: 20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.scene-title h1 { font-size: 22px; letter-spacing: 1px; }
|
||||
.title-sub { font-size: 12px; }
|
||||
.title-meta { display: none; }
|
||||
|
||||
/* 紧凑态下正文标题略大于品牌字号,层级保持「页面 → 品牌」的正确指向 */
|
||||
.scene-title h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
.slogan-top { font-size: 13px; letter-spacing: 6px; }
|
||||
.slogan-main { font-size: 34px; letter-spacing: 4px; }
|
||||
.slogan-en { display: none; }
|
||||
|
||||
.scene-benefits {
|
||||
padding: 0 0 0 28px;
|
||||
gap: clamp(22px, 4vw, 54px);
|
||||
}
|
||||
.benefit-icon { width: 34px; height: 34px; }
|
||||
.benefit-icon .el-icon { font-size: 16px; }
|
||||
|
||||
.scene-benefits { gap: clamp(10px, 2vw, 28px); }
|
||||
.scene-benefits b { font-size: 12px; }
|
||||
.scene-benefits small { display: none; font-size: 12px; }
|
||||
|
||||
.admin-shell {
|
||||
height: calc(100vh - 170px);
|
||||
}
|
||||
|
||||
.scene-footer {
|
||||
height: 28px;
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,9 @@
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never" class="inner-card">
|
||||
<template #header><span>手工签到(特殊情形)</span></template>
|
||||
<el-input v-model="manualUserId" placeholder="输入用户 ID" />
|
||||
<el-input v-model="manualUserNumber" placeholder="输入13位用户编号" />
|
||||
<div class="btn-row">
|
||||
<el-button type="primary" :loading="saving" :disabled="!manualUserId"
|
||||
<el-button type="primary" :loading="saving" :disabled="!manualUserNumber"
|
||||
@click="manual">手工签到</el-button>
|
||||
</div>
|
||||
<div class="form-hint">
|
||||
@@ -65,8 +65,8 @@
|
||||
<el-card v-if="lastResult" shadow="never" class="inner-card result-card">
|
||||
<template #header><span>最近一次签到</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="用户 ID">{{ lastResult.userId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="活动 ID">{{ lastResult.activityId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户编号">{{ lastResult.userDisplayNumber }}</el-descriptions-item>
|
||||
<el-descriptions-item label="活动编号">{{ lastResult.activityDisplayId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="本次是否为重复签到">
|
||||
<el-tag :type="lastResult.alreadyCheckedIn ? 'warning' : 'success'" size="small">
|
||||
{{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }}
|
||||
@@ -96,7 +96,7 @@ import { get, post } from '@/api/client'
|
||||
const activities = ref<any[]>([])
|
||||
const activityId = ref('')
|
||||
const token = ref('')
|
||||
const manualUserId = ref('')
|
||||
const manualUserNumber = ref('')
|
||||
const stats = ref<any>(null)
|
||||
const lastResult = ref<any>(null)
|
||||
const scanning = ref(false)
|
||||
@@ -151,15 +151,15 @@ async function scan() {
|
||||
}
|
||||
|
||||
async function manual() {
|
||||
if (!activityId.value || !manualUserId.value.trim()) return
|
||||
if (!activityId.value || !manualUserNumber.value.trim()) return
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await post(
|
||||
`/activities/operator/${activityId.value}/checkin/manual`,
|
||||
{ userId: manualUserId.value.trim() },
|
||||
{ userId: manualUserNumber.value.trim() },
|
||||
authHeader())
|
||||
lastResult.value = { ...res.data, userId: manualUserId.value.trim(), activityId: activityId.value }
|
||||
manualUserId.value = ''
|
||||
lastResult.value = { ...res.data, userDisplayNumber: res.data?.userDisplayNumber || manualUserNumber.value.trim(), activityDisplayId: activities.value.find((item: any) => item.id === activityId.value)?.displayId }
|
||||
manualUserNumber.value = ''
|
||||
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(未重复记录)' : '手工签到成功')
|
||||
await loadStats()
|
||||
} finally {
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>活动互选管理</span>
|
||||
<el-input v-model="searchActivityId" placeholder="活动ID搜索" clearable style="width:260px" />
|
||||
<el-input v-model="searchActivityNumber" placeholder="活动编号搜索" clearable style="width:260px" />
|
||||
<el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="activityId" label="活动ID" width="160" />
|
||||
<el-table-column prop="activityDisplayId" label="活动编号" width="160" />
|
||||
<el-table-column prop="activityTitle" label="活动名称" min-width="180" />
|
||||
<el-table-column label="双向互选对数" width="140">
|
||||
<template #default="{row}">{{ row.mutualPairs }} 对</template>
|
||||
@@ -31,8 +31,8 @@
|
||||
<div v-if="currentActivity">
|
||||
<h4>{{ currentActivity.title }} — 双向互选列表</h4>
|
||||
<el-table :data="mutualList" border size="small" style="margin-top:16px">
|
||||
<el-table-column prop="sourceUserId" label="表达者ID" min-width="180" />
|
||||
<el-table-column prop="targetUserId" label="被表达者ID" min-width="180" />
|
||||
<el-table-column prop="sourceUserDisplayNumber" label="表达者编号" min-width="180" />
|
||||
<el-table-column prop="targetUserDisplayNumber" label="被表达者编号" min-width="180" />
|
||||
<el-table-column prop="confirmedAt" label="确认时间" width="180" />
|
||||
</el-table>
|
||||
<el-empty v-if="!mutualList.length" description="暂无双向互选记录" />
|
||||
@@ -46,7 +46,7 @@ import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const searchActivityId = ref('')
|
||||
const searchActivityNumber = ref('')
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const drawerVisible = ref(false)
|
||||
@@ -59,8 +59,8 @@ async function loadData() {
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
|
||||
const rows = res.data || []
|
||||
tableData.value = searchActivityId.value
|
||||
? rows.filter((row: any) => row.activityId.includes(searchActivityId.value.trim()))
|
||||
tableData.value = searchActivityNumber.value
|
||||
? rows.filter((row: any) => (row.activityDisplayId || '').includes(searchActivityNumber.value.trim()))
|
||||
: rows
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="displayId" label="活动编号" min-width="180" />
|
||||
<el-table-column prop="title" label="活动名称" min-width="180" />
|
||||
<el-table-column prop="activityType" label="活动类型" width="130">
|
||||
<template #default="{ row }">
|
||||
@@ -128,7 +129,6 @@
|
||||
<el-dialog v-model="orgVisible" title="活动组织与容量分组" width="640px">
|
||||
<h4>组织关系</h4>
|
||||
<el-table :data="orgs" border size="small">
|
||||
<el-table-column prop="organizationId" label="组织ID" min-width="160" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column prop="role" label="角色" width="120">
|
||||
<template #default="{ row }">{{ roleText(row.role) }}</template>
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="operatorId" label="操作人ID" width="140" />
|
||||
<el-table-column prop="operatorDisplayId" label="操作人编号" min-width="180" />
|
||||
<el-table-column prop="action" label="操作" width="160">
|
||||
<template #default="{row}">{{ actionText(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resourceType" label="资源类型" width="120">
|
||||
<template #default="{row}">{{ row.resourceType }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resourceId" label="资源ID" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="resourceDisplayId" label="业务编号" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.resourceDisplayId || '未公开编号(已脱敏)' }}</template></el-table-column>
|
||||
<el-table-column prop="ip" label="IP地址" width="140" />
|
||||
<el-table-column prop="createdAt" label="时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
|
||||
@@ -179,7 +179,7 @@ function isExternalLink(value: string) {
|
||||
function previewTargetText(banner: any) {
|
||||
const dest = TARGET_TYPE_DEST[banner.targetType] || banner.targetType || '无'
|
||||
if (!banner.targetValue) return dest
|
||||
return `${dest} · ${banner.targetValue}`
|
||||
return `${dest} · 已配置站内目标`
|
||||
}
|
||||
function assignForm(source: any) {
|
||||
form.value = {
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<el-dialog v-model="assignDialogVisible" title="分配咨询师" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="咨询师">
|
||||
<el-input v-model="assignForm.counselorId" placeholder="请输入咨询师工号 / ID" />
|
||||
<el-input v-model="assignForm.counselorNumber" placeholder="请输入13位工作人员编号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -143,7 +143,7 @@ const assignedServices = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const followupDialogVisible = ref(false)
|
||||
const currentId = ref('')
|
||||
const assignForm = ref({ counselorId: '' })
|
||||
const assignForm = ref({ counselorNumber: '' })
|
||||
const followupForm = ref({ type: 'phone_call', content: '' })
|
||||
|
||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
@@ -211,19 +211,19 @@ function onTabChange(name: string | number) {
|
||||
|
||||
function openAssign(row: any) {
|
||||
currentId.value = row.id
|
||||
assignForm.value.counselorId = ''
|
||||
assignForm.value.counselorNumber = ''
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function doAssign() {
|
||||
if (!assignForm.value.counselorId) {
|
||||
ElMessage.warning('请填写咨询师 ID')
|
||||
if (!assignForm.value.counselorNumber) {
|
||||
ElMessage.warning('请填写咨询师编号')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await post(`/counseling/services/operator/${currentId.value}/assign`,
|
||||
{ counselorId: assignForm.value.counselorId })
|
||||
{ counselorId: assignForm.value.counselorNumber })
|
||||
ElMessage.success('分配成功')
|
||||
assignDialogVisible.value = false
|
||||
loadQueue()
|
||||
|
||||
@@ -2,11 +2,24 @@
|
||||
<div class="matchmaker-management">
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>公益红娘管理</span></div></template>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tabs v-model="activeTab" @tab-change="refresh">
|
||||
<el-tab-pane v-if="canAssign" label="服务进展与跟进" name="management">
|
||||
<el-table :data="managedServices" stripe v-loading="loading">
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="150" />
|
||||
<el-table-column prop="operatorDisplayId" label="红娘编号" min-width="150" />
|
||||
<el-table-column label="服务状态" width="120"><template #default="{ row }">{{ statusLabel(row.status) }}</template></el-table-column>
|
||||
<el-table-column prop="latestFollowupContent" label="最新跟进" min-width="230" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="110"><template #default="{ row }">
|
||||
<el-button link type="primary" @click="openServiceDetail(row.serviceId, true)">查看记录</el-button>
|
||||
</template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<!-- ZC-MM-002:服务请求队列仅分配权持有者可见(matchmaker.assign 或 matchmaker.team.assign) -->
|
||||
<el-tab-pane v-if="canAssign" label="服务请求队列" name="services">
|
||||
<el-table :data="services" stripe v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" width="190">
|
||||
<template #default="{ row }">{{ row.userDisplayNumber || '实名后生成' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="serviceType" label="服务类型" width="160">
|
||||
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
|
||||
</el-table-column>
|
||||
@@ -21,6 +34,7 @@
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button>
|
||||
<el-button v-if="row.status==='assigned' || row.status==='in_service'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
|
||||
<el-button v-if="row.status!=='submitted'" size="small" @click="openServiceDetail(row.serviceId || row.id)">查看服务</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -28,8 +42,8 @@
|
||||
|
||||
<el-tab-pane label="推荐列表" name="recommendations">
|
||||
<el-table :data="recommendations" stripe v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户" width="110" />
|
||||
<el-table-column prop="targetUserId" label="推荐对象" width="110" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户" width="110" />
|
||||
<el-table-column prop="targetUserDisplayNumber" label="推荐对象" width="110" />
|
||||
<el-table-column prop="reason" label="推荐理由" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="130">
|
||||
<template #default="{ row }"><el-tag :type="recStatusTag(row.status)">{{ recStatusLabel(row.status) }}</el-tag></template>
|
||||
@@ -43,7 +57,9 @@
|
||||
<!-- ZC-MM-002 新增:服务对象分配(本人名下生效中的分配 + 转分配/撤销/结束) -->
|
||||
<el-tab-pane label="服务对象分配" name="assignments">
|
||||
<el-table :data="assignments" stripe v-loading="assignmentsLoading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" width="190">
|
||||
<template #default="{ row }">{{ row.userDisplayNumber || '实名后生成' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assignmentScope" label="范围" width="90">
|
||||
<template #default="{ row }">{{ assignmentScopeLabel(row.assignmentScope) }}</template>
|
||||
</el-table-column>
|
||||
@@ -59,6 +75,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.serviceId" size="small" @click="openServiceDetail(row.serviceId)">查看服务</el-button>
|
||||
<el-button size="small" type="primary" @click="openTransfer(row)">转分配</el-button>
|
||||
<el-button size="small" @click="openTerminate(row, 'revoke')">撤销</el-button>
|
||||
<el-button size="small" @click="openTerminate(row, 'end')">结束</el-button>
|
||||
@@ -69,8 +86,8 @@
|
||||
<!-- ZC-MM-003 / B1 立项:受控联系授权(审批岗队列 + 本人授权/撤销) -->
|
||||
<el-tab-pane label="联系授权审批" name="contactAuths">
|
||||
<el-table :data="contactAuths" stripe v-loading="contactAuthsLoading">
|
||||
<el-table-column prop="operatorId" label="红娘ID" width="120" />
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="operatorDisplayId" label="红娘编号" width="120" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" width="120" />
|
||||
<el-table-column prop="channel" label="渠道" width="110">
|
||||
<template #default="{ row }">{{ contactChannelLabel(row.channel) }}</template>
|
||||
</el-table-column>
|
||||
@@ -151,8 +168,8 @@
|
||||
<!-- 创建推荐弹窗 -->
|
||||
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="推荐给用户"><el-input v-model="recForm.userId" disabled /></el-form-item>
|
||||
<el-form-item label="推荐对象ID"><el-input v-model="recForm.targetUserId" placeholder="输入被推荐对象的用户ID" /></el-form-item>
|
||||
<el-form-item label="推荐给用户"><el-input v-model="recForm.userDisplayNumber" disabled /></el-form-item>
|
||||
<el-form-item label="推荐对象编号"><el-input v-model="recForm.targetUserDisplayNumber" placeholder="输入13位用户编号" /></el-form-item>
|
||||
<el-form-item label="推荐理由"><el-input v-model="recForm.reason" type="textarea" :rows="3" placeholder="说明为什么这个对象适合该用户" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -160,16 +177,67 @@
|
||||
<el-button type="primary" @click="doCreateRecommendation">发送推荐</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="serviceDetailVisible" title="服务详情" width="620px">
|
||||
<el-descriptions v-if="serviceDetail" :column="2" border>
|
||||
<el-descriptions-item label="服务编号">{{ serviceDetail.serviceDisplayId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel(serviceDetail.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务类型">{{ serviceTypeLabel(serviceDetail.serviceType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(serviceDetail.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务需求" :span="2">{{ serviceDetail.description || '未填写' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider content-position="left">服务历史</el-divider>
|
||||
<el-empty v-if="!serviceDetail?.followups?.length" description="暂无服务记录" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item v-for="item in serviceDetail.followups" :key="item.id" :timestamp="formatDateTime(item.createdAt)">
|
||||
{{ item.content }}
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<template v-if="serviceDetail && !managementDetail">
|
||||
<el-divider />
|
||||
<el-button type="primary" @click="openFollowup">添加跟进记录</el-button>
|
||||
<el-button
|
||||
v-if="['completed', 'cancelled'].indexOf(serviceDetail.status) < 0"
|
||||
type="success"
|
||||
plain
|
||||
@click="completeCurrentService"
|
||||
>完成服务</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="followupDialogVisible" title="添加跟进记录" width="520px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="跟进方式">
|
||||
<el-select v-model="followupForm.followupType" style="width: 100%">
|
||||
<el-option label="电话沟通" value="phone_call" />
|
||||
<el-option label="微信沟通" value="wechat" />
|
||||
<el-option label="面对面" value="face_to_face" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="跟进内容" required>
|
||||
<el-input v-model="followupForm.content" type="textarea" :rows="4" placeholder="记录本次服务进展" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="followupDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitFollowup">保存记录</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onActivated } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const managementDetail = ref(false)
|
||||
const managedServices = ref<any[]>([])
|
||||
// ZC-MM-002:分配权 = matchmaker.assign(平台授权)或 matchmaker.team.assign(红娘组长)
|
||||
const canAssign = computed(() => {
|
||||
const perms: string[] = authStore.permissions || []
|
||||
@@ -186,6 +254,7 @@ const assignableMatchmakers = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const transferDialogVisible = ref(false)
|
||||
const terminateDialogVisible = ref(false)
|
||||
const recDialogVisible = ref(false)
|
||||
const currentServiceId = ref('')
|
||||
const currentUserId = ref('')
|
||||
const currentAssignmentId = ref('')
|
||||
@@ -193,7 +262,11 @@ const terminateAction = ref<'revoke' | 'end'>('revoke')
|
||||
const assignForm = ref({ matchmakerOperatorId: '', reason: '' })
|
||||
const transferForm = ref({ newMatchmakerOperatorId: '', reason: '' })
|
||||
const terminateForm = ref({ reason: '' })
|
||||
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
|
||||
const recForm = ref({ userDisplayNumber: '', targetUserDisplayNumber: '', reason: '' })
|
||||
const serviceDetailVisible = ref(false)
|
||||
const serviceDetail = ref<any>(null)
|
||||
const followupDialogVisible = ref(false)
|
||||
const followupForm = ref({ followupType: 'phone_call', content: '' })
|
||||
|
||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
recommendation: '推荐对象', referral_counseling: '转介咨询',
|
||||
@@ -248,6 +321,49 @@ function contactChannelLabel(s: string) { return CONTACT_CHANNEL_LABEL[s] || s }
|
||||
function contactStatusLabel(s: string) { return CONTACT_STATUS_LABEL[s] || s }
|
||||
function contactStatusTag(s: string) { return CONTACT_STATUS_TAG[s] || 'info' }
|
||||
|
||||
async function openServiceDetail(serviceId: string, management = false) {
|
||||
managementDetail.value = management
|
||||
if (!serviceId) return
|
||||
try {
|
||||
const res: any = await get(`/operator/matchmaker/${management ? 'management/' : ''}services/${serviceId}`)
|
||||
serviceDetail.value = res?.data || null
|
||||
serviceDetailVisible.value = true
|
||||
} catch { serviceDetail.value = null }
|
||||
}
|
||||
|
||||
function openFollowup() {
|
||||
followupForm.value = { followupType: 'phone_call', content: '' }
|
||||
followupDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitFollowup() {
|
||||
if (!followupForm.value.content.trim() || !serviceDetail.value?.serviceId) {
|
||||
ElMessage.warning('请填写跟进内容')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await post(`/operator/matchmaker/services/${serviceDetail.value.serviceId}/followup`, followupForm.value)
|
||||
ElMessage.success('跟进记录已保存')
|
||||
followupDialogVisible.value = false
|
||||
await openServiceDetail(serviceDetail.value.serviceId)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '保存跟进记录失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function completeCurrentService() {
|
||||
if (!serviceDetail.value?.serviceId) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认将该红娘服务标记为已完成吗?', '完成服务', { type: 'warning' })
|
||||
await post(`/operator/matchmaker/services/${serviceDetail.value.serviceId}/complete`, {})
|
||||
ElMessage.success('服务已完成')
|
||||
await openServiceDetail(serviceDetail.value.serviceId)
|
||||
await loadAssignments()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel' && e !== 'close') ElMessage.error(e?.response?.data?.message || '完成服务失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ZC-MM-003 / B1:受控联系授权 =====
|
||||
// 审批岗客户端可见性提示(真实判定在 Service 层动态执行:principal 权限 ∈ approver-authorities 配置)
|
||||
const canApprove = computed(() => {
|
||||
@@ -259,7 +375,13 @@ const contactAuthsLoading = ref(false)
|
||||
|
||||
async function loadContactAuths() {
|
||||
contactAuthsLoading.value = true
|
||||
try { const res: any = await get('/operator/matchmaker/contact-authorizations/my'); contactAuths.value = res?.data?.list || [] } finally { contactAuthsLoading.value = false }
|
||||
try {
|
||||
const endpoint = canApprove.value
|
||||
? '/operator/matchmaker/contact-authorizations/pending'
|
||||
: '/operator/matchmaker/contact-authorizations/my'
|
||||
const res: any = await get(endpoint)
|
||||
contactAuths.value = res?.data?.list || []
|
||||
} finally { contactAuthsLoading.value = false }
|
||||
}
|
||||
async function doContactApprove(row: any) {
|
||||
try {
|
||||
@@ -306,7 +428,12 @@ async function loadAssignableMatchmakers() {
|
||||
try { const res: any = await get('/operator/matchmaker/assignments/assignable-matchmakers'); assignableMatchmakers.value = res?.data || [] } catch { assignableMatchmakers.value = [] }
|
||||
}
|
||||
async function refresh() {
|
||||
if (activeTab.value === 'services') await loadServices()
|
||||
if (activeTab.value === 'management') {
|
||||
loading.value = true
|
||||
try { const res: any = await get('/operator/matchmaker/management/services'); managedServices.value = res?.data || [] }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
else if (activeTab.value === 'services') await loadServices()
|
||||
else if (activeTab.value === 'assignments') await loadAssignments()
|
||||
else if (activeTab.value === 'contactAuths') await loadContactAuths()
|
||||
else await loadRecommendations()
|
||||
@@ -373,21 +500,27 @@ async function doTerminate() {
|
||||
|
||||
function openRecommendation(row: any) {
|
||||
currentServiceId.value = row.id
|
||||
recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
|
||||
recForm.value = { userDisplayNumber: row.userDisplayNumber || '实名后生成', targetUserDisplayNumber: '', reason: '' }
|
||||
recDialogVisible.value = true
|
||||
}
|
||||
async function doCreateRecommendation() {
|
||||
if (!recForm.value.targetUserId || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return }
|
||||
if (!recForm.value.targetUserDisplayNumber || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return }
|
||||
// Phase 13 契约修复:原 POST /matchmaker/services/{id}/recommendation → 后端为 /operator/matchmaker/services/{id}/recommend
|
||||
try {
|
||||
await post(`/operator/matchmaker/services/${currentServiceId.value}/recommend`, {
|
||||
targetUserId: recForm.value.targetUserId, reason: recForm.value.reason
|
||||
targetUserId: recForm.value.targetUserDisplayNumber, reason: recForm.value.reason
|
||||
})
|
||||
ElMessage.success('推荐已发送给用户'); recDialogVisible.value = false; loadRecommendations()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
onActivated(async () => {
|
||||
if (canAssign.value && typeof route.query.serviceId === 'string') {
|
||||
activeTab.value = 'management'
|
||||
await openServiceDetail(route.query.serviceId, true)
|
||||
}
|
||||
await refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="username" label="用户名" width="140" />
|
||||
<el-table-column prop="displayId" label="人员编号" width="150" />
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column prop="organizationId" label="所属机构" width="160">
|
||||
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<el-table-column prop="type" label="类型" width="120">
|
||||
<template #default="{ row }"><el-tag>{{ typeLabel(row.type) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="关联ID" width="120" />
|
||||
<el-table-column prop="referralDisplayId" label="转介编号" min-width="180" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }"><el-tag :type="row.status==='confirmed'?'success':'warning'">{{ row.status }}</el-tag></template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane :label="`待审核队列(${pendingTasks.length})`" name="pending">
|
||||
<el-table :data="pendingTasks" v-loading="loading" row-key="taskId">
|
||||
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="displayId" label="任务编号" min-width="180" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="reviewType" label="审核类型" min-width="130">
|
||||
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
||||
</el-table-column>
|
||||
@@ -42,7 +43,8 @@
|
||||
|
||||
<el-tab-pane :label="`我的审核(${myTasks.length})`" name="my">
|
||||
<el-table :data="myTasks" v-loading="loading" row-key="taskId">
|
||||
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="displayId" label="任务编号" min-width="180" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="reviewType" label="审核类型" min-width="130">
|
||||
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
|
||||
</el-table-column>
|
||||
@@ -65,7 +67,7 @@
|
||||
<div v-if="detailLoading" v-loading="true" class="drawer-loading"></div>
|
||||
<template v-else-if="currentTask">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="用户编号">{{ currentTask.userId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户编号">{{ currentTask.userDisplayNumber }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核类型">{{ reviewTypeText(currentTask.reviewType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前状态">
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="reasonText" label="举报原因" width="140" />
|
||||
<el-table-column prop="reportedUserId" label="被举报人ID" width="140" />
|
||||
<el-table-column prop="displayId" label="举报编号" min-width="180" />
|
||||
<el-table-column prop="reportedUserDisplayNumber" label="被举报人编号" width="140" />
|
||||
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{row}">
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
</el-radio-group>
|
||||
</div></template>
|
||||
<el-table :data="appointments" stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="100" />
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="displayId" label="预约编号" min-width="180" />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="180" />
|
||||
<el-table-column prop="serviceType" label="服务类型" width="150" />
|
||||
<el-table-column prop="source" label="来源" width="140">
|
||||
<template #default="{ row }"><el-tag size="small">{{ sourceLabel(row.source) }}</el-tag></template>
|
||||
@@ -21,7 +21,7 @@
|
||||
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="preferredDate" label="预约日期" width="110" />
|
||||
<el-table-column prop="assignedOperatorId" label="分配人员" width="100" />
|
||||
<el-table-column prop="assignedOperatorDisplayId" label="分配人员" width="100" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<el-descriptions-item label="合计">{{ stats.total }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-table :data="members" stripe v-loading="memberLoading">
|
||||
<el-table-column prop="userId" label="用户ID" width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="userDisplayNumber" label="用户编号" width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="memberStatusTag(row.status)">{{ memberStatusLabel(row.status) }}</el-tag>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<section v-else-if="user" class="surface-card detail-card">
|
||||
<div class="identity-summary">
|
||||
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
|
||||
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.id }}</span></div>
|
||||
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.displayNumber || '实名后生成' }}</span></div>
|
||||
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="var(--admin-coral-solid)" />
|
||||
</div>
|
||||
<el-tabs v-model="activeTab" class="detail-tabs">
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
</el-alert>
|
||||
|
||||
<el-table v-else :data="tableData" v-loading="loading" row-key="id">
|
||||
<el-table-column prop="id" label="用户编号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="displayNumber" label="用户编号" min-width="190" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.displayNumber || '实名后生成' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="displayName" label="显示名称" min-width="110">
|
||||
<template #default="{ row }">{{ row.displayName || '未设置' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -18,6 +18,20 @@ interface ApiEnvelope<T> {
|
||||
|
||||
const REQUEST_TIMEOUT = 8000
|
||||
|
||||
export function summarizeMatchmakerServices(rows: JsonRecord[]) {
|
||||
const day = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||
const today = day.format(new Date())
|
||||
return {
|
||||
pending: rows.filter(row => ['submitted', 'assigned', 'in_service', 'waiting_user'].includes(String(row.status))).length,
|
||||
completed: rows.filter(row => row.status === 'completed').length,
|
||||
total: rows.filter(row => ['createdAt', 'assignedAt', 'completedAt', 'latestFollowupAt'].some(key => {
|
||||
if (!row[key]) return false
|
||||
const date = new Date(String(row[key]))
|
||||
return !Number.isNaN(date.getTime()) && day.format(date) === today
|
||||
})).length,
|
||||
}
|
||||
}
|
||||
|
||||
const numberOf = (source: JsonRecord | undefined, key: string) => {
|
||||
const value = source?.[key]
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
@@ -45,9 +59,10 @@ const emptyMetric = (
|
||||
|
||||
export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel {
|
||||
return {
|
||||
followups: [],
|
||||
metrics: [
|
||||
emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'),
|
||||
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/referral'),
|
||||
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/matchmaker'),
|
||||
emptyMetric('counseling_todos', '咨询服务待办', 'blue', '/counseling'),
|
||||
emptyMetric('station_appointments', '服务站预约', 'green', '/service-appointment'),
|
||||
emptyMetric('effective_matches', '有效匹配', 'purple', '/analytics'),
|
||||
@@ -65,8 +80,11 @@ export function createEmptyDashboard(notice = '当前账号暂无可展示的工
|
||||
export async function loadDashboard(permissions: string[]): Promise<DashboardModel> {
|
||||
const canViewAnalytics = permissions.includes('analytics.view')
|
||||
const canViewActivities = permissions.includes('activity.view')
|
||||
const canViewMatchmaker = permissions.includes('matchmaker.view')
|
||||
const canManageMatchmaker = permissions.includes('matchmaker.assign') || permissions.includes('matchmaker.team.assign')
|
||||
const canViewReviews = permissions.includes('review.view')
|
||||
|
||||
const [coreResult, serviceResult, activityResult] = await Promise.allSettled([
|
||||
const [coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult] = await Promise.allSettled([
|
||||
canViewAnalytics
|
||||
? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT })
|
||||
: Promise.resolve(null),
|
||||
@@ -76,14 +94,33 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
canViewActivities
|
||||
? client.get('/operator/activities', { timeout: REQUEST_TIMEOUT })
|
||||
: Promise.resolve(null),
|
||||
canViewMatchmaker
|
||||
? client.get('/operator/matchmaker/dashboard', { timeout: REQUEST_TIMEOUT })
|
||||
: Promise.resolve(null),
|
||||
canViewReviews ? client.get('/operator/reviews/stats', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
|
||||
canManageMatchmaker ? client.get('/operator/matchmaker/management/services', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
|
||||
])
|
||||
|
||||
const core = fulfilledData<JsonRecord>(coreResult)
|
||||
const service = fulfilledData<JsonRecord>(serviceResult)
|
||||
const activityRows = fulfilledData<JsonRecord[]>(activityResult)
|
||||
const matchmaker = fulfilledData<JsonRecord>(matchmakerResult)
|
||||
const reviews = fulfilledData<JsonRecord>(reviewResult)
|
||||
const managedServices = fulfilledData<JsonRecord[]>(managementResult)
|
||||
const managedStats = summarizeMatchmakerServices(managedServices || [])
|
||||
const matchmakerAvailable = canManageMatchmaker ? Boolean(managedServices) : Boolean(matchmaker)
|
||||
const matchmakerPending = canManageMatchmaker
|
||||
? managedStats.pending
|
||||
: numberOf(matchmaker || undefined, 'pendingCount') + numberOf(matchmaker || undefined, 'inProgressCount')
|
||||
const matchmakerCompleted = canManageMatchmaker
|
||||
? managedStats.completed
|
||||
: numberOf(matchmaker || undefined, 'completedCount')
|
||||
const followups = (managedServices || []).filter(row => row.latestFollowupContent)
|
||||
.sort((a, b) => String(b.latestFollowupAt).localeCompare(String(a.latestFollowupAt)))
|
||||
.slice(0, 5).map(row => ({ serviceId: String(row.serviceId), userDisplayNumber: String(row.userDisplayNumber || '未生成编号'),
|
||||
content: String(row.latestFollowupContent), createdAt: String(row.latestFollowupAt || '') }))
|
||||
const analyticsAvailable = Boolean(core || service)
|
||||
|
||||
const eligibility = recordOf(service || undefined, 'localEligibility')
|
||||
const counseling = recordOf(service || undefined, 'counseling')
|
||||
const appointments = recordOf(service || undefined, 'serviceAppointments')
|
||||
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
|
||||
@@ -92,12 +129,13 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
|
||||
const metrics: DashboardMetric[] = [
|
||||
{
|
||||
key: 'pending_reviews', label: '待审核资料', value: numberOf(eligibility, 'pending'),
|
||||
trendText: service ? '本地资格待核验' : '暂无授权数据', tone: 'rose', available: Boolean(service), route: '/review',
|
||||
key: 'pending_reviews', label: '待审核资料', value: numberOf(reviews || undefined, 'pending') + numberOf(reviews || undefined, 'processing'),
|
||||
trendText: reviews ? '待领取与审核中资料' : '暂无授权数据', tone: 'rose', available: Boolean(reviews), route: '/review',
|
||||
},
|
||||
{
|
||||
key: 'matchmaker_todos', label: '红娘服务待办', value: 0,
|
||||
trendText: '当前接口未提供待办数', tone: 'amber', available: false, route: '/referral',
|
||||
key: 'matchmaker_todos', label: '红娘服务待办',
|
||||
value: matchmakerPending,
|
||||
trendText: matchmakerAvailable ? (canManageMatchmaker ? '管理范围内待完成服务' : '本人服务对象待跟进') : '暂无授权数据', tone: 'amber', available: matchmakerAvailable, route: '/matchmaker',
|
||||
},
|
||||
{
|
||||
key: 'counseling_todos', label: '咨询服务待办',
|
||||
@@ -120,9 +158,10 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
]
|
||||
|
||||
const todos = ([
|
||||
{ key: 'review', title: '资料审核', description: '本地资格材料等待核验', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available },
|
||||
{ key: 'review', title: '资料审核', description: '待领取与审核中资料', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available },
|
||||
{ key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metrics[2].value, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metrics[2].available },
|
||||
{ key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metrics[3].value, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metrics[3].available },
|
||||
{ key: 'matchmaker', title: '红娘服务', description: metrics[1].trendText, count: metrics[1].value, actionText: '查看服务', route: '/matchmaker', tone: 'amber', available: metrics[1].available },
|
||||
{ key: 'risk', title: '举报处理', description: '风险举报等待工作人员处理', count: numberOf(core || undefined, 'pendingReports'), actionText: '去处理', route: '/risk/reports', tone: 'amber', available: Boolean(core) },
|
||||
] as DashboardTodo[]).filter((item) => item.available)
|
||||
|
||||
@@ -142,6 +181,7 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
{ key: 'approved', label: '资料审核通过', value: numberOf(core || undefined, 'approvedProfiles'), compareText: '已通过资料', tone: 'blue', available: Boolean(core) },
|
||||
{ key: 'served', label: '公益服务用户', value: numberOf(uniqueUsers, 'totalUniqueServiceUsers'), compareText: '去重服务人数', tone: 'green', available: Boolean(service) },
|
||||
{ key: 'success', label: '成功牵手', value: numberOf(core || undefined, 'successPairs'), compareText: '双方确认成功', tone: 'amber', available: Boolean(core) },
|
||||
{ key: 'matchmaker_completed', label: '红娘服务完成', value: matchmakerCompleted, compareText: canManageMatchmaker ? '管理范围内已完成服务' : '本人已完成服务', tone: 'amber', available: matchmakerAvailable },
|
||||
] as DashboardOverviewItem[]).filter((item) => item.available)
|
||||
|
||||
const effectiveness = ([
|
||||
@@ -152,12 +192,14 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
|
||||
{ key: 'offline_success', label: '线下牵手成功', value: numberOf(effectiveConnections, 'successConnections'), max: numberOf(effectiveConnections, 'activeMatches'), unit: '对', available: Boolean(service) },
|
||||
] as DashboardEffectivenessItem[]).filter((item) => item.available)
|
||||
|
||||
const rejectedCount = [coreResult, serviceResult, activityResult].filter((item) => item.status === 'rejected').length
|
||||
const notice = !analyticsAvailable && !activityRows
|
||||
const rejectedCount = [coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult].filter((item) => item.status === 'rejected').length
|
||||
const anyData = analyticsAvailable || Boolean(activityRows) || matchmakerAvailable || Boolean(reviews)
|
||||
const notice = !anyData
|
||||
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
|
||||
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
|
||||
|
||||
return {
|
||||
followups,
|
||||
metrics,
|
||||
todos,
|
||||
activities,
|
||||
|
||||
@@ -37,8 +37,18 @@
|
||||
--admin-coral-soft: #FFF0F1; /* 品牌浅底(角标 / 选中态底) */
|
||||
--admin-coral-lighter: #FFF8F7; /* 更浅一档,hover 底 */
|
||||
--admin-coral-border: rgba(244, 95, 104, 0.22);
|
||||
--admin-scene-bg-start: #FFEDEE;
|
||||
--admin-scene-bg-mid: #FFF6F4;
|
||||
--admin-scene-bg-end: #FBF5F4;
|
||||
--admin-scene-glow: rgba(244, 95, 104, 0.28);
|
||||
--admin-scene-glow-soft: rgba(255, 150, 170, 0.22);
|
||||
--admin-scene-surface: rgba(255, 255, 255, 0.65);
|
||||
--admin-scene-border: rgba(244, 95, 104, 0.18);
|
||||
--admin-scene-divider: rgba(244, 95, 104, 0.30);
|
||||
/* 承载白字的品牌渐变(两端均 ≥4.5:1)。基础色渐变见 --gradient-brand-vivid。 */
|
||||
--gradient-brand-solid: linear-gradient(135deg, #C84A53 0%, #B8434A 100%);
|
||||
/* 文字用渐变(background-clip:text):比 solid 更鲜亮,用于「善缘丹阳」等大字号品牌标题 */
|
||||
--gradient-brand-text: linear-gradient(135deg, #FF7D6E 0%, #E8475D 60%, #B8434A 100%);
|
||||
/* 鲜亮品牌渐变:只允许用在「无文字覆盖」的装饰,或预览态还原(如 Banner 预览)。 */
|
||||
--gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%);
|
||||
--gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%);
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface DashboardEffectivenessItem {
|
||||
}
|
||||
|
||||
export interface DashboardModel {
|
||||
followups: { serviceId: string; userDisplayNumber: string; content: string; createdAt: string }[]
|
||||
metrics: DashboardMetric[]
|
||||
todos: DashboardTodo[]
|
||||
activities: DashboardActivity[]
|
||||
|
||||
@@ -106,6 +106,17 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<article v-if="dashboard.followups.length" class="dashboard-panel">
|
||||
<header class="panel-header"><div><h2>最新红娘跟进记录</h2></div></header>
|
||||
<div class="todo-list">
|
||||
<button v-for="item in dashboard.followups" :key="item.serviceId" type="button" class="todo-row"
|
||||
@click="goTo(`/matchmaker?serviceId=${encodeURIComponent(item.serviceId)}`)">
|
||||
<span class="todo-copy"><strong>{{ item.userDisplayNumber }}</strong><small>{{ item.content }}</small></span>
|
||||
<em>{{ formatDateTime(item.createdAt) }} · 查看服务</em>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section class="dashboard-grid dashboard-grid-bottom">
|
||||
<article class="dashboard-panel service-overview-panel">
|
||||
<header class="panel-header"><div><h2>服务数据概览</h2></div><span>当前累计</span></header>
|
||||
@@ -136,7 +147,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onActivated, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
@@ -277,15 +288,19 @@ async function retryLoad() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(retryLoad)
|
||||
onActivated(retryLoad)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
max-width: var(--admin-content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5) 26px 28px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dashboard-welcome {
|
||||
@@ -478,6 +493,19 @@ onMounted(retryLoad)
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.dashboard-grid-top {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-grid-bottom {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dashboard-grid-bottom .dashboard-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-panel {
|
||||
min-width: 0;
|
||||
padding: 14px var(--space-4);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<main class="login-shell">
|
||||
<section class="brand-panel" aria-label="善缘丹阳公益婚恋服务平台">
|
||||
<section class="brand-panel" aria-label="爱在丹阳公益婚恋服务平台">
|
||||
<img class="brand-scene" :src="BRAND_LOGIN_SCENE" alt="" />
|
||||
<div class="brand-overlay">
|
||||
<div class="brand-title">
|
||||
<img :src="BRAND_LOGO" alt="" />
|
||||
<div>
|
||||
<strong>善缘丹阳</strong>
|
||||
<strong>爱在丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@
|
||||
<span>敏感数据按权限访问,所有关键操作均记录审计日志。</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer>善缘丹阳 · 公益婚恋服务平台</footer>
|
||||
<footer>爱在丹阳 · 公益婚恋服务平台</footer>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -135,6 +135,8 @@ async function handleLogin() {
|
||||
.brand-title img {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
clip-path: circle(50%);
|
||||
}
|
||||
|
||||
.brand-title div {
|
||||
|
||||
Reference in New Issue
Block a user