Files
YschcmsFront/src/views/cms/components/SiteMainCard.vue
T
chenxing cff4325947
build / build (push) Successful in 2m20s
[CMS-VISUAL] 站点管理改版: 表格→站点工作台(主站宽卡+院系子站卡片网格)+ 页头承载站点身份 + v3.1 视觉修订(182 测试绿)
- SiteManage.vue: 8 列表格改为站点工作台,按 schId 分组「主站宽卡 + 院系子站网格」,
  保留卡片⇄表格视图切换与搜索过滤;scope=all 全局视图支持多校分组,搜索时主站卡始终保留(层级锚点)
- 新增 views/cms/components/SiteCard.vue、SiteMainCard.vue
- 删除 components/SiteContextCard.vue: 站点身份改由页头「学校名.站点名」承担(SchoolLayout),
  侧栏不再铺第二份站点列表,切站入口从 3 处收敛到 2 处(面包屑 + 站点工作台)
- v3.1 视觉修订: 色带语义统一为「当前站」(全页同时只有一张卡带选中态,不再一页两处蓝);
  删除面包屑 band-sub 橙色接缝条(身份已收回页头);返回按钮 ios-undo 18×18 → ios-arrow-back 26×26 可点方块;
  删除 SiteManage .zone-tip 设计说明文字(误当 UI 渲染)
- 测试: siteLayer.spec.ts 站点卡 4 用例重写为页头反向/正向断言,siteManage.spec.ts 补卡片视图断言;182 用例绿
- 方案: docs/22-站点管理修订方案.md(含 §9 v3.1 视觉修订与像素对照表)
2026-09-28 18:32:13 +08:00

237 lines
6.6 KiB
Vue

<script setup lang="ts">
import { computed } from "vue";
import { assetUrl } from "@/api/module/cms";
import { portalUrlOf } from "@/utils/sitePath";
import { SiteVo, ThemeVo } from "@/types/cms";
/**
* 学校主站宽卡(docs/22):站点工作台的层级锚点。
* 主站唯一、子站都挂在它下面 —— 横向宽卡独占一行(左侧 4px 主色带),
* 与子站网格在版式上分区;层级不靠「所属主站」列的文字表达(那是旧表格的做法)。
*/
const props = defineProps<{
site: SiteVo;
isCurrent: boolean;
theme?: ThemeVo;
/** 本校院系子站总数(由父级按全量行统计,搜索态下不随过滤变化) */
subCount: number;
}>();
const emit = defineEmits<{
(e: "enter", site: SiteVo): void;
(e: "setting", site: SiteVo): void;
(e: "portal", site: SiteVo): void;
}>();
const disabled = computed(() => props.site.status !== 1);
const enterDisabled = computed(() => disabled.value || props.isCurrent);
/** 统计:子站数本地可算(必显);栏目/文章同 SiteCard —— 后端补齐字段后自动出现 */
const stats = computed(() => {
const s = props.site as any;
const list: { num: string | number; lbl: string }[] = [];
if (s.categoryCount != null) list.push({ num: s.categoryCount, lbl: "栏目" });
if (s.articleCount != null) list.push({ num: s.articleCount, lbl: "文章" });
list.push({ num: props.subCount, lbl: "子站" });
return list;
});
const themeThumb = computed(() => (props.theme?.screenshotUrl ? assetUrl(props.theme.screenshotUrl) : ""));
const themeTitle = computed(() => props.theme?.title || props.site.theme || "default");
</script>
<template>
<div class="main-card" :class="{ 'is-cur': isCurrent, disabled }">
<div class="mc-body">
<div class="mc-left">
<span class="sc-ico" :class="disabled ? 'off' : ''">
<Icon type="ios-school" />
</span>
<div class="sc-id">
<div class="sc-name">
<h4 :title="site.name">{{ site.name }}</h4>
<span class="sc-badge main">主站</span>
<span v-if="isCurrent" class="sc-badge cur">当前</span>
<span v-else-if="disabled" class="sc-badge off">停用</span>
</div>
<p class="sc-code">{{ site.code }} · {{ portalUrlOf(site) }}</p>
<div class="sc-theme">
<span class="thumb" :style="themeThumb ? { backgroundImage: `url(${themeThumb})` } : null"></span>
<span class="tname" :title="themeTitle">
{{ themeTitle }}
<span v-if="site.theme" class="tspec">{{ site.theme }}</span>
</span>
</div>
</div>
</div>
<div class="mc-stats">
<div class="st" v-for="st in stats" :key="st.lbl">
<div class="num">{{ st.num }}</div>
<div class="lbl">{{ st.lbl }}</div>
</div>
</div>
<div class="mc-actions">
<Button type="primary" :disabled="enterDisabled" @click="emit('enter', site)">进入管理</Button>
<Button @click="emit('setting', site)">站点设置</Button>
<Button @click="emit('portal', site)">门户预览</Button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
/* 主站宽卡:与子站卡共用同一套「当前站」语义 —— 4px 左色带 + 蓝描边 + 淡蓝光环,
全页同时只有一张卡带选中样式(原色带常驻表达「根节点」,会被误读成选中态)。
层级改由版式表达:宽卡独占一行 + 「主站」徽章 + 「N 子站」统计。
色带走 ::before 绝对定位而非 border-left —— 用边框会在切换态时把内容挤偏 3px。 */
.main-card {
position: relative;
overflow: hidden;
background: #fff;
border: 1px solid #e8eaec;
border-radius: 8px;
margin-bottom: 26px;
&.is-cur {
border-color: #9db4f7;
box-shadow: 0 0 0 2px rgba(87, 122, 251, 0.12);
}
&.is-cur::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: #577afb;
}
&.disabled {
opacity: 0.62;
background: #fafbfc;
}
}
.mc-body {
display: flex;
align-items: center;
gap: 20px;
padding: 18px 20px;
}
.mc-left {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
flex: 1;
}
.sc-ico {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
font-size: 19px;
color: #fff;
background: #577afb;
&.off {
background: #c0c4cc;
}
}
.sc-id {
min-width: 0;
flex: 1;
}
.sc-name {
display: flex;
align-items: center;
gap: 6px;
h4 {
margin: 0;
font-size: 17px;
color: #17233d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.sc-badge {
flex-shrink: 0;
font-size: 10px;
line-height: 16px;
padding: 0 5px;
border-radius: 3px;
color: #fff;
&.main {
background: #577afb;
}
&.cur {
background: #2ba471;
}
&.off {
background: #c0c4cc;
}
}
.sc-code {
margin: 3px 0 0;
font-size: 12px;
color: #808695;
font-family: Consolas, Monaco, monospace;
}
.sc-theme {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0 0;
font-size: 12px;
color: #656667;
.thumb {
width: 52px;
height: 32px;
border-radius: 4px;
border: 1px solid #e8eaec;
flex-shrink: 0;
background: #f0f2f5 center/cover no-repeat;
}
.tname {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tspec {
color: #a0a6b0;
font-family: Consolas, Monaco, monospace;
font-size: 11px;
}
}
.mc-stats {
display: flex;
flex-shrink: 0;
.st {
padding: 0 18px;
border-left: 1px solid #eef0f3;
text-align: center;
&:first-child {
border-left: none;
}
}
.num {
font-size: 19px;
font-weight: bold;
color: #17233d;
line-height: 1.2;
}
.lbl {
font-size: 11px;
color: #a0a6b0;
margin-top: 2px;
}
}
.mc-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
</style>