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>