This commit is contained in:
itcsx committed 2026-09-18 17:45:56 +08:00
1 parent 61869f1af0
commit 9344b56334
5 files changed
+626 -10

No files matched your search

@@ -0,0 +1,59 @@
<template>
<nav class="dash-nav">
<a v-for="n in items" :key="n.key" class="nav-item" @click="onClick(n.key)">
<cust-icon-font :type="n.icon" :size="22" />
<span>{{ n.label }}</span>
</a>
</nav>
</template>
<script lang="ts" setup>
type NavItem = { key: string; label: string; icon: string };
defineProps<{ items: NavItem[] }>();
const emit = defineEmits<{
(e: "jump", key: string): void;
}>();
const onClick = (key: string) => emit("jump", key);
</script>
<style lang="less" scoped>
.dash-nav {
display: flex;
align-items: center;
justify-content: space-around;
height: 52px;
padding-bottom: env(safe-area-inset-bottom);
background: #fff;
}
/* 宽屏时与卡片区(1080px)对齐,避免图标被拉得太散 */
@media (min-width: 1200px) {
.dash-nav {
max-width: 1080px;
margin: 0 auto;
}
}
/* 字号对齐 ft 体系:辅助文字 12px(同 .ft-item-label / .ft-chip) */
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px 10px;
color: #8a919f;
font-size: 12px;
line-height: 16px;
cursor: pointer;
text-decoration: none;
transition: color 0.16s;
&:hover,
&:active {
color: #1677ff;
}
}
</style>
@@ -0,0 +1,208 @@
<template>
<section class="grp-card">
<!-- 封面:有 cover 用图片,否则用分组主题色渐变 + 大号水印图标 -->
<div class="grp-cover" :class="{ 'has-image': !!cover, 'grp-test': !cover }" :style="coverStyle">
<cust-icon-font v-if="!cover" class="grp-cover-mark" :type="icon" :size="132" />
<span class="grp-badge">{{ badge || name }}</span>
</div>
<!-- 功能列表:逐行排列,左侧软色图标 + 标题副标题 + 右侧圆形分组图标 -->
<div class="grp-list">
<a v-for="it in items" :key="it.FunctionName" class="grp-row" :title="it.FunctionLocalName"
@click="onClick(it.ApiRoute)">
<span class="row-icon" :style="{ background: soft, color }">
<cust-icon-font :type="it.Icon || icon" :size="20" />
</span>
<span class="row-text">
<span class="row-title">{{ it.FunctionLocalName }}</span>
<span v-if="subtitleOf(it)" class="row-sub">{{ subtitleOf(it) }}</span>
</span>
<span class="row-glyph" :style="{ background: soft, color }">
<cust-icon-font :type="icon" :size="18" />
</span>
</a>
</div>
</section>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import type { SecurityFunctionDisplayData } from "@/data/auto/securityFunctionDisplayData";
/** 单项可额外携带 subtitle,用于封面卡里的两行文案 */
export type GroupCardItem = SecurityFunctionDisplayData & { subtitle?: string };
const props = defineProps<{
name: string;
/** 分组主题色:图标着色、无封面时的渐变封面 */
color: string;
/** 主题浅色:图标底色 */
soft: string;
icon: string;
/** 兼容旧参数:仅作为布局提示,当前统一为纵向列表 */
layout?: 2 | 3;
/** 封面图片地址;缺省时用主题色渐变占位 */
cover?: string;
/** 封面左上角标签;缺省时显示分组名称 */
badge?: string;
items: GroupCardItem[];
}>();
const emit = defineEmits<{
(e: "navigate", route: string): void;
}>();
const coverStyle = computed(() =>
props.cover
? { backgroundImage: `url(${props.cover})` }
: { background: `linear-gradient(135deg, ${props.color} 0%, ${props.soft} 100%)` }
);
/** 副标题优先取自定义 subtitle,其次回落英文名;都为空则不渲染该行 */
const subtitleOf = (it: GroupCardItem) => (it.subtitle || it.FunctionEngName || "").trim();
const onClick = (route?: string) => {
if (route) emit("navigate", route);
};
</script>
<style lang="less" scoped>
.grp-card {
overflow: visible;
margin-bottom: 12px;
border-radius: 18px;
background: #fff;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.05);
}
/* ==================== 封面 ==================== */
.grp-cover {
position: relative;
aspect-ratio: 16 / 10;
max-height: 360px;
border-radius: 18px 18px 0 0;
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
overflow: visible;
}
.grp-test {
position: relative;
aspect-ratio: 40 / 10;
max-height: 360px;
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
}
.grp-cover-mark {
position: absolute;
right: -8px;
bottom: -20px;
z-index: 3;
color: rgba(255, 255, 255, 0.32);
pointer-events: none;
}
.grp-badge {
position: absolute;
top: 16px;
left: 12px;
z-index: 2;
padding: 2px 10px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.28);
color: #fff;
font-size: 36px;
line-height: 58px;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
/* ==================== 列表 ==================== */
.grp-list {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border-radius: 0 0 18px 18px;
background: #b9cbee;
}
.grp-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
background: #fff;
color: inherit;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition:
transform 0.16s ease,
box-shadow 0.16s ease;
&:hover {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
}
&:active {
transform: scale(0.98);
}
}
.row-icon {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
}
.row-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.row-title {
font-size: 15px;
font-weight: 600;
color: #1f2329;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-sub {
font-size: 12px;
color: #8a919f;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-glyph {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
}
@media (min-width: 1200px) {
.grp-card {
max-width: 1080px;
margin-right: auto;
margin-left: auto;
}
}
</style>
@@ -0,0 +1,252 @@
<template>
<div class="hero-shell">
<section class="hero">
<div class="hero-inner">
<div class="hero-top">
<div class="brand">
<a-avatar
shape="square"
:size="36"
src="/youjia.svg"
class="brand-mark"
/>
<span>有家相册</span>
</div>
<a-dropdown placement="bottomRight" :trigger="['click']">
<div class="avatar" :title="userName">{{ avatarText }}</div>
<template #overlay>
<a-menu @click="onUserMenuClick">
<a-menu-item key="amentUserLocalName">
<UserOutlined />
{{ t("layout.header.amentUserLocalName") }}
</a-menu-item>
<a-menu-item key="amentPassword">
<LockOutlined />
{{ t("layout.header.amentPassword") }}
</a-menu-item>
<a-menu-item key="biometricButton">
<LockOutlined />
{{ biometricButtonLabel }}
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">
<LogoutOutlined />
{{ t("layout.header.logout") }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</section>
<div class="hero-card">
<div class="hello">你好,{{ userName }} <span class="wave">👋</span></div>
<div class="sub">欢迎回家,记录新美好。</div>
</div>
</div>
<changePassword v-model:showModel="showChangePassword" />
<changeLocalName v-model:showModel="showChangeUserLocalName" />
<changeBiometricEnrollCard v-model:showModel="showBiometricEnroll" />
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { notification } from "ant-design-vue";
import type { MenuProps } from "ant-design-vue";
import { LockOutlined, LogoutOutlined, UserOutlined } from "@ant-design/icons-vue";
import authService from "@/service/security/authService";
import { useLoginProfileStore } from "@/store/modules/loginProfile";
import i18n from "@/locales";
import changePassword from "@/views/login/changePassword.vue";
import changeLocalName from "@/views/login/changeLocalName.vue";
import changeBiometricEnrollCard from "@/views/login/changeBiometricEnrollCard.vue";
const router = useRouter();
const profile = useLoginProfileStore();
const { t } = i18n.global;
const userName = computed(
() => profile.getUserLocalName() || profile.getUsername() || "新朋友"
);
const avatarText = computed(() => {
const u = (userName.value || "U").trim();
return u ? u.slice(-1).toUpperCase() : "U";
});
/* ==================== 头像菜单 ==================== */
/* 欢迎页使用独立外壳(layout/home.vue),没有原顶栏,账号操作挂到右上角头像上 */
const showChangePassword = ref<boolean>(false);
const showChangeUserLocalName = ref<boolean>(false);
const showBiometricEnroll = ref<boolean>(false);
// Coarse platform detection so the button label is friendly on each device.
const biometricButtonLabel = computed(() => {
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
if (/iPhone|iPad/.test(ua)) return "Sign in with Face ID / Touch ID";
if (/Android/.test(ua)) return "Sign in with fingerprint";
if (/Windows/.test(ua)) return "Sign in with Windows Hello";
if (/Mac OS X/.test(ua)) return "Sign in with Touch ID";
return "Sign in with fingerprint / face";
});
const onUserMenuClick: MenuProps["onClick"] = (menuInfo) => {
if (menuInfo.key === "amentUserLocalName") {
showChangeUserLocalName.value = true;
} else if (menuInfo.key === "amentPassword") {
showChangePassword.value = true;
} else if (menuInfo.key === "biometricButton") {
showBiometricEnroll.value = true;
} else if (menuInfo.key === "logout") {
logout();
}
};
const logout = async () => {
await authService.Logout();
profile.clearStorage();
notification.info({
message: t("layout.notification.message"),
description: t("layout.notification.description"),
placement: "topRight",
});
router.push("/");
};
</script>
<style lang="less" scoped>
/* 渐变头图:只承载顶栏(品牌+头像),不再包含卡片,避免被卡片内容撑高 */
.hero {
padding: 18px 16px 36px;
border-radius: 0 0 22px 22px;
background: linear-gradient(135deg, #1677ff 0%, #36cfc9 100%);
color: #fff;
box-shadow: 0 6px 18px rgba(22, 119, 255, 0.22);
}
/* hero 的外壳,把 hero 与 hero-card 编为一组,作为页面里的非伸缩块 */
.hero-shell {
flex: none;
}
.hero-inner {
margin: 0 auto;
}
/* 上层半透明卡片:放在 hero 外面,用负 margin-top 叠到 hero 底部。
顶部与 hero 重叠处保持半透明让渐变透出,下方不透明避免露出页面灰底 */
.hero-card {
margin: -24px 16px 18px;
padding: 20px 18px;
border-radius: 20px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.55) 0%,
rgba(255, 255, 255, 0.6) 30%,
rgba(255, 255, 255, 0.95) 45%,
rgba(255, 255, 255, 0.96) 100%
);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
box-shadow:
0 14px 34px rgba(15, 23, 42, 0.14),
0 4px 12px rgba(15, 23, 42, 0.08);
color: #1f2329;
}
.hero-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
}
.brand-mark {
display: flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: rgba(255, 255, 255, 0.22);
overflow: hidden;
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: #1677ff;
font-size: 14px;
font-weight: 700;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
cursor: pointer;
user-select: none;
}
.hello {
font-size: 22px;
font-weight: 700;
line-height: 1.3;
}
.wave {
margin-left: 2px;
}
.sub {
margin-top: 10px;
margin-left: 60px;
font-size: 20px;
opacity: 0.9;
}
@media (min-width: 576px) {
.hero {
padding: 22px 20px 40px;
}
.hero-card {
margin: -28px 20px 22px;
padding: 22px 22px;
}
.hello {
font-size: 26px;
}
}
@media (min-width: 1200px) {
/* 宽屏下把 hero + card 整体居中到 1080,避免渐变条在大屏拉得过宽 */
.hero-shell {
max-width: 1080px;
margin-right: auto;
margin-left: auto;
}
.hero-inner {
max-width: 1080px;
margin-right: auto;
margin-left: auto;
}
.hero {
padding: 24px 24px 44px;
}
.hero-card {
margin: -28px 24px 24px;
}
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<template>
<div class="home-layout">
<!-- 内容区:整屏除底部信息条外全部留给页面 -->
<div class="home-viewport">
<router-view />
</div>
</div>
</template>
<script lang="ts" setup>
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
/**
* 欢迎页(/dashboard)专用外壳。
* 不套用后台的侧边菜单 / 顶栏 / 面包屑,整屏交给页面自己排版:
* home-layout(列:内容 + 信息条,100dvh 不出现窗口滚动条)
* └ home-viewport(自适应剩余高度,内容由页面内部滚动)
*/
const copy: string = WebSitePolicy.Copy;
const beian: string = WebSitePolicy.Beian;
</script>
<style lang="less" scoped>
.home-layout {
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: #f5f6f8;
}
.home-viewport {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.home-footer {
flex: none;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 4px 10px;
padding: 6px 12px;
border-top: 1px solid #eef0f4;
background: #fff;
color: #8a919f;
font-size: 11px;
line-height: 16px;
text-align: center;
a {
color: #8a919f;
text-decoration: none;
transition: color 0.16s;
&:hover {
color: #1677ff;
}
}
}
</style>
+42 -10
View File
@@ -7,8 +7,8 @@
<!-- 功能分组:卡片外壳 / 标题字号沿用 ft-page 体系 -->
<div class="page-main ft-page">
<DashboardGroupCard v-for="grp in groups" :id="'grp-' + grp.key" :key="grp.key" :name="grp.name"
:color="grp.color" :soft="grp.soft" :icon="grp.icon" :layout="grp.layout" :items="grp.items"
@navigate="onpush" />
:color="grp.color" :soft="grp.soft" :icon="grp.icon" :layout="grp.layout" :cover="grp.cover"
:badge="grp.badge" :items="grp.items" @navigate="onpush" />
<a-empty v-if="!groups.length && !loading" description="没有匹配的功能" />
</div>
@@ -27,7 +27,7 @@ import { useRouter } from "vue-router";
import securityUserService from "@/service/security/securityUserService";
import type { SecurityFunctionDisplayData } from "@/data/auto/securityFunctionDisplayData";
import DashboardHero from "@/components/dashboard/DashboardHero.vue";
import DashboardGroupCard from "@/components/dashboard/DashboardGroupCard.vue";
import DashboardGroupCard, { type GroupCardItem } from "@/components/dashboard/DashboardGroupCard.vue";
import DashboardBottomNav from "@/components/dashboard/DashboardBottomNav.vue";
interface NavItem {
@@ -51,13 +51,18 @@ const groupDefs: {
soft: string;
icon: string;
layout: 2 | 3;
/** 封面图片地址(可选),缺省时由卡片用主题色渐变占位 */
cover?: string;
/** 封面左上角标签(可选),缺省时显示分组名称 */
badge?: string;
}[] = [
{ key: "disk", name: "网盘", color: "#1677ff", soft: "#e6f4ff", icon: "dbank", layout: 2 },
{ key: "share", name: "共享中心", color: "#fa8c16", soft: "#fff7e6", icon: "dgroup", layout: 2 },
{ key: "disk", name: "网盘", color: "#1677ff", soft: "#e6f4ff", icon: "dbank", layout: 2},
{ key: "share", name: "共享中心", color: "#fa8c16", soft: "#fff7e6", icon: "dgroup", layout: 2},
// { key: "photo", name: "相册", color: "#722ed1", soft: "#f9f0ff", icon: "picture", layout: 3, cover: "/images/main/p4.png" },
{ key: "photo", name: "相册", color: "#722ed1", soft: "#f9f0ff", icon: "picture", layout: 3 },
{ key: "audio", name: "音乐", color: "#eb2f96", soft: "#fff0f6", icon: "foldermp3", layout: 3 },
{ key: "audio", name: "音乐", color: "#eb2f96", soft: "#fff0f6", icon: "foldermp3", layout: 3},
{ key: "video", name: "视频", color: "#13c2c2", soft: "#e6fffb", icon: "foldervideo", layout: 3 },
{ key: "family", name: "族谱管理", color: "#52c41a", soft: "#f6ffed", icon: "family", layout: 2 },
{ key: "family", name: "族谱管理", color: "#52c41a", soft: "#f6ffed", icon: "family", layout: 2},
{ key: "other", name: "更多", color: "#8a919f", soft: "#f5f6f8", icon: "gailan", layout: 3 },
];
@@ -86,19 +91,46 @@ const GROUP_OF: Record<string, GroupKey> = {
const resolveGroup = (it: SecurityFunctionDisplayData): GroupKey =>
GROUP_OF[it.FunctionName] ?? "other";
/* ==================== 自定义副标题 ==================== */
/** 留空时卡片会自动回退到 FunctionEngName */
const SUBTITLE_OF: Record<string, string> = {
DmsFileEnquiry: "上传、下载",
DmsFileDeleteEnquiry: "",
FamilyGroupShare: "",
FamilyShareOperation: "",
FamilyGroupOperation: "",
FamilyGroupEnquiry: "",
Preview: "",
PublishPhotoOperation: "",
PublishPhotoEnquiry: "",
PreviewAudio: "播放从默认专辑",
PublishAudioOperation: "",
PublishAudioEnquiry: "",
PreviewVideo: "",
PublishVideoOperation: "",
PublishVideoEnquiry: "",
FamilyPersonEnquiry: "",
FamilyPersonOperation: "",
FamilyTreeEnquiry: "",
FamilyTreeOperation: "",
};
const resolveSubtitle = (it: SecurityFunctionDisplayData): string =>
SUBTITLE_OF[it.FunctionName] ?? "";
/* ==================== 分组结果 ==================== */
const groups = computed(() => {
const taken = new Set<string>();
const result: { key: GroupKey; name: string; color: string; soft: string; icon: string; layout: 2 | 3; items: SecurityFunctionDisplayData[] }[] = [];
const result: { key: GroupKey; name: string; color: string; soft: string; icon: string; layout: 2 | 3; cover?: string; badge?: string; items: GroupCardItem[] }[] = [];
for (const g of groupDefs) {
const items: SecurityFunctionDisplayData[] = [];
const items: GroupCardItem[] = [];
for (const it of allItems.value) {
if (resolveGroup(it) !== g.key) continue;
const label = it.FunctionLocalName || it.FunctionName;
if (taken.has(label)) continue;
taken.add(label);
items.push(it);
items.push({ ...it, subtitle: resolveSubtitle(it) });
}
if (items.length) result.push({ ...g, items });
}