This commit is contained in:
owenchen committed 2026-10-13 17:02:14 +08:00
1 parent 32f237c4bf
commit 8a2adb0a92
18 files changed
+418 -53

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
@@ -69,6 +69,15 @@ export function clearLoginState() {
uni.removeStorageSync(JWT_STORAGE_KEY);
}
/**
* 是否已有登录会话:只认请求层的 AccessToken(令牌唯一来源,与 nav-guard 口径一致)。
* 不在此判过期——AccessToken 过期时请求层会拿 RefreshToken 自动续期,续期失败再 goLogin 退回登录页,
* 所以"登录过"就足以决定冷启动是否直接进 dashboard。
*/
export function hasLoginSession(): boolean {
return !!getJwtInfo()?.AccessToken;
}
function getToken(): string {
return getJwtInfo()?.AccessToken ?? "";
}
@@ -188,9 +188,8 @@ defineExpose({ refresh, clearSelection, selectedRows });
{{ filterCount }}
</text>
</view>
</view>
<view v-if="$slots.toolbar" class="toolbar-extra">
<slot name="toolbar" />
<!-- 页面可在此放次级动作按钮(如"新增"),排在筛选右侧、只留图标 -->
<slot name="toolbar-actions" />
</view>
</template>
@@ -302,17 +301,6 @@ defineExpose({ refresh, clearSelection, selectedRows });
border-radius: 16rpx;
}
/* 页面自定义的次级工具(如"新增")走白底一行,与搜索栏区分 */
.toolbar-extra {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
padding: 4rpx 24rpx 16rpx;
background-color: #ffffff;
border-bottom: 1rpx solid $u-border-color;
}
.card {
display: flex;
flex-direction: row;
@@ -2,8 +2,9 @@
</script>
<script setup lang="ts">
import { $u } from "uview-pro";
import { ref } from "vue";
import { $u, useLocale } from "uview-pro";
import { computed, onMounted } from "vue";
import { PAGE_PATHS, pageUrl, TAB_PAGES } from "@/router/routes";
import { useTabbarStore } from "@/stores";
defineOptions({
@@ -16,14 +17,82 @@ defineOptions({
},
});
const { tabbarList } = useTabbarStore();
const { t } = useLocale();
const store = useTabbarStore();
const current = ref<number>(0);
/**
* 把 store 里的"结构配置"翻译成 u-tabbar 需要的 item:文字按当前语言 t(langKey) 取,
* 因此切换语言时 tab 文案会自动跟着变(t 依赖 currentLocale,computed 会重新求值)。
*
* props 收进一个对象再 v-bind:既避开长标签被格式化器折行后触发 html-indent 对撞,
* 也集中覆盖 u-tabbar 几个默认值不合口味的 prop:
* - hideTabBar 必须 false:其 onMounted 默认调 uni.hideTabBar(),本项目是自定义 tabbar
* (无原生 pages.json tabBar),该调用必 reject 成 "hideTabBar:fail not TabBar page" 并被
* 全局错误边界弹成错误框。
* - midButton 必须 true:否则 item.midButton 的凸起样式不生效(中心「多媒体」大按钮)。
*/
const tabbarProps = computed(() => ({
modelValue: store.activeIndex,
list: store.tabs.map((tab, index) => ({
text: t(tab.langKey),
iconPath: tab.iconPath,
selectedIconPath: tab.selectedIconPath,
midButton: tab.midButton,
count: store.badges[index] ?? 0,
})),
activeColor: $u.color.primary,
midButton: true,
// 中心凸起的圆标图片尺寸:显式带单位,撑满 u-tabbar 写死的 100rpx 凸起圆(留一点白边当描边)
midButtonSize: "100rpx",
iconSize: 40,
textSize: 26,
hideTabBar: false,
}));
/**
* 由当前页面真实路径反推 tab 下标。
* 不依赖 activeIndex 的持久值:冷启动直达 / 403 回首页时持久值可能是上一次会话的旧下标,
* 按路由算才能保证高亮一定落在真正显示的这一页上。getCurrentPages 给的是不带前导斜杠的真实路径。
*/
function indexOfCurrentRoute(): number {
const pages = getCurrentPages();
const route = pages[pages.length - 1]?.route ?? "";
return TAB_PAGES.findIndex(name => PAGE_PATHS[name] === route);
}
onMounted(() => {
const index = indexOfCurrentRoute();
if (index >= 0) {
store.setActiveIndex(index);
}
});
/** item 未配 pagePath,u-tabbar 只会 emit change 而不自行 switchTab;跳转在这里做 */
function onChange(index: number): void {
if (index === store.activeIndex) {
return;
}
store.setActiveIndex(index);
// reLaunch 清空页面栈,符合"切 tab 不叠加历史"的直觉,也会被 nav-guard 正常拦截鉴权
uni.reLaunch({ url: pageUrl(TAB_PAGES[index]) });
}
</script>
<template>
<u-tabbar v-model="current" :list="tabbarList" :active-color="$u.color.primary" />
<u-tabbar v-bind="tabbarProps" @change="onChange" />
</template>
<style lang="scss" scoped>
/*
* 给底部导航加"上分隔线 + 向上投影",让它与页面内容之间有明显层次。
* 作用在 .u-tabbar__content(真正带背景色的那条)上:
* - border-top:比 u-border-top 自带的 1px 淡线更清晰一点;
* - box-shadow:负 y 偏移让阴影朝上投到页面上,形成浮起感。
* 注意:borderTop prop 仍保持默认 true —— 中心凸起按钮的缺口白圆(circle__border)
* 依赖它才渲染,关掉会让熊猫圆标和导航条衔接处出现割裂。
*/
:deep(.u-tabbar__content) {
border-top: 1rpx solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -6rpx 28rpx rgba(0, 0, 0, 0.18);
}
</style>
@@ -7,6 +7,13 @@
"cancelBut": "Cancel",
"cultureLabel": "Language:"
},
"TabBar": {
"home": "Home",
"file": "Files",
"media": "Media",
"share": "Share",
"mine": "Me"
},
"AdminPage": {
"filterBut": "Filter",
"filterTitle": "Filter Conditions",
@@ -7,6 +7,13 @@
"cancelBut": "取消",
"cultureLabel": "语言:"
},
"TabBar": {
"home": "首页",
"file": "文件",
"media": "多媒体",
"share": "共享",
"mine": "我的"
},
"AdminPage": {
"filterBut": "筛选",
"filterTitle": "筛选条件",
+13
View File
@@ -28,6 +28,19 @@
},
{
"path": "pages/security/actor-enquiry"
},
// 底部导航测试页(首页复用上面的 dashboard):文件/多媒体/共享/我的
{
"path": "pages/tab/file"
},
{
"path": "pages/tab/media"
},
{
"path": "pages/tab/share"
},
{
"path": "pages/tab/mine"
}
],
// 全局配置
@@ -32,7 +32,7 @@ useAuthGuard(() => {
</script>
<template>
<app-page :nav-title="WebSitePolicy.Title" :show-nav-back="false" :hide-nav="true">
<app-page :nav-title="WebSitePolicy.Title" :show-nav-back="false" :hide-nav="true" :show-tabbar="true">
<view class="dashboard">
<view v-if="menu.loading.value" class="dashboard__tip">
<text>{{ t("AdminPage.menuLoading") }}</text>
+15 -2
View File
@@ -4,10 +4,10 @@
// 加密已抽到 @/common/pwd-crypto(wxmp-rsa 纯 JS 实现,H5/小程序/App 全端可用)
import type { LoginData } from "@/data/auto/loginData";
import type { LoginProfile } from "@/data/auto/loginProfile";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { backendLocale, baseUrl } from "@/common/http.interceptor";
import { backendLocale, baseUrl, hasLoginSession } from "@/common/http.interceptor";
import { encryptLoginPassword } from "@/common/pwd-crypto";
import { LoginData as LoginDataClass } from "@/data/auto/loginData";
import { reloadLocales } from "@/locale";
@@ -35,6 +35,19 @@ onLoad((options) => {
}
});
/**
* 冷启动直接落在登录入口且本地已有登录会话 → 自动进 dashboard,
* 省掉"明明登录过还闪一下登录页"。令牌若已过期,dashboard 首个请求会触发请求层用
* RefreshToken 自动续期;续期失败再 goLogin 退回本页,不会卡在受保护页里。
* 被"有意送回登录页"的场景(401 去登录 / 403 重新登录)不必再设豁免:
* goLoginWithRedirect 已先清登录态,此刻 hasLoginSession 必为假,表单正常展示。
*/
onShow(() => {
if (hasLoginSession()) {
uni.reLaunch({ url: redirectUrl.value });
}
});
// 登录页中英文切换:走 uView Pro 语言包(t / setLocale),语言包 name 为 zh-CN / en-US
const { t, setLocale, currentLocale } = useLocale();
const isZh = computed(() => (currentLocale.value?.name ?? "zh-CN") !== "en-US");
@@ -164,12 +164,9 @@ useAuthGuard(() => {
:filter-items="draft" :search-token="searchToken" @search="onSearch" @open-filter="state.openFilter()"
@close-filter="state.closeFilter()" @apply-filter="state.applyFilter($event)"
@clear-filter="state.clearFiltersAndSearch()" @row-tap="onRowTap">
<template #toolbar>
<template #toolbar-actions>
<view v-if="perm.canCreate.value" class="create-entry" @click="onCreate">
<u-icon name="plus" size="24" color="#ffffff" />
<text class="create-text">
{{ t("AdminPage.createBut") }}
</text>
<u-icon name="plus" size="40" />
</view>
</template>
@@ -188,21 +185,17 @@ useAuthGuard(() => {
background-color: $u-bg-color;
}
/* 新增:翠绿实心胶囊,靠右落在次级工具行里 */
/* 新增:与筛选按钮同一行的方形图标钮,只留一个加号 */
.create-entry {
display: flex;
flex-direction: row;
align-items: center;
padding: 12rpx 28rpx;
color: #ffffff;
background-color: $u-type-primary;
border-radius: 999rpx;
box-shadow: 0 6rpx 16rpx rgba(5, 150, 105, 0.24);
}
.create-text {
margin-left: 8rpx;
font-size: 26rpx;
font-weight: 500;
justify-content: center;
width: 64rpx;
height: 64rpx;
margin-left: 12rpx;
color: $u-type-primary;
background-color: $u-bg-color;
border-radius: 16rpx;
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
/**
* 底部导航「文件」测试页:先占位,后续接相册/文件功能。
* 权限暂用 UserOperation(见 routes.ts 的 PAGE_PERMISSIONS);nav-guard 在 reLaunch 时做路由级校验,
* 这里再挂一次 useAuthGuard 兜冷启动直达(地址栏 / file、分享链接不经过导航 API)。
*/
import { useAuthGuard } from "@/composables";
useAuthGuard();
</script>
<template>
<app-page nav-title="文件" :show-nav-back="false" :show-tabbar="true">
<view class="tab-page">
<text class="tab-page__title">
文件
</text>
<text class="tab-page__hint">
测试页,功能待接入
</text>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.tab-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 40rpx;
}
.tab-page__title {
font-size: 40rpx;
font-weight: 600;
color: $u-main-color;
}
.tab-page__hint {
margin-top: 16rpx;
font-size: 26rpx;
color: $u-tips-color;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
/**
* 底部导航「多媒体」测试页:先占位,后续接照片/视频/音频。
* 权限暂用 UserOperation(见 routes.ts 的 PAGE_PERMISSIONS);useAuthGuard 兜冷启动直达。
*/
import { useAuthGuard } from "@/composables";
useAuthGuard();
</script>
<template>
<app-page nav-title="多媒体" :show-nav-back="false" :show-tabbar="true">
<view class="tab-page">
<text class="tab-page__title">
多媒体
</text>
<text class="tab-page__hint">
测试页,功能待接入
</text>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.tab-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 40rpx;
}
.tab-page__title {
font-size: 40rpx;
font-weight: 600;
color: $u-main-color;
}
.tab-page__hint {
margin-top: 16rpx;
font-size: 26rpx;
color: $u-tips-color;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
/**
* 底部导航「我的」测试页:先占位,后续接个人资料/设置/关于入口。
* 权限暂用 UserOperation(见 routes.ts 的 PAGE_PERMISSIONS);useAuthGuard 兜冷启动直达。
*/
import { useAuthGuard } from "@/composables";
useAuthGuard();
</script>
<template>
<app-page nav-title="我的" :show-nav-back="false" :show-tabbar="true">
<view class="tab-page">
<text class="tab-page__title">
我的
</text>
<text class="tab-page__hint">
测试页,功能待接入
</text>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.tab-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 40rpx;
}
.tab-page__title {
font-size: 40rpx;
font-weight: 600;
color: $u-main-color;
}
.tab-page__hint {
margin-top: 16rpx;
font-size: 26rpx;
color: $u-tips-color;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
/**
* 底部导航「共享」测试页:先占位,后续接家庭组/共享管理。
* 权限暂用 UserOperation(见 routes.ts 的 PAGE_PERMISSIONS);useAuthGuard 兜冷启动直达。
*/
import { useAuthGuard } from "@/composables";
useAuthGuard();
</script>
<template>
<app-page nav-title="共享" :show-nav-back="false" :show-tabbar="true">
<view class="tab-page">
<text class="tab-page__title">
共享
</text>
<text class="tab-page__hint">
测试页,功能待接入
</text>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.tab-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 40rpx;
}
.tab-page__title {
font-size: 40rpx;
font-weight: 600;
color: $u-main-color;
}
.tab-page__hint {
margin-top: 16rpx;
font-size: 26rpx;
color: $u-tips-color;
}
</style>
+6 -1
View File
@@ -11,7 +11,7 @@
* 拦截器拦不到,必须由页面自己调用 composables/useAuthGuard(页面级 onShow 校验)。
*/
import type { ErrorPageName, PageName } from "@/router/routes";
import { getJwtInfo } from "@/common/http.interceptor";
import { clearLoginState, getJwtInfo } from "@/common/http.interceptor";
import { canVisitPage } from "@/permission/permission-context";
import {
errorPageUrl,
@@ -126,8 +126,13 @@ export function setupUnknownPathFallback(): void {
/**
* 供页面把用户送回登录页(带 redirect,登录成功后回到原地址;缺省回 dashboard)
* redirect 与 from 一样不做预编码,理由见 routes.ts 的 errorPageUrl
*
* 进登录页前先清登录态:走到这里都是"有意去登录/换号"(401 去登录、403 重新登录),
* 旧会话整体作废——否则登录页的冷启动自动放行(hasLoginSession)会把这个还带着令牌的
* 用户直接弹回原页,403"重新登录"根本够不到登录表单。
*/
export function goLoginWithRedirect(from?: string): void {
clearLoginState();
const target = from && resolvePageName(from) ? from : pageUrl("dashboard");
uni.reLaunch({
url: `${pageUrl("login")}?redirect=${normalizePath(target)}`,
+25
View File
@@ -19,6 +19,11 @@ export const PAGE_PATHS = {
userEnquiry: "pages/security/user-enquiry",
userOperation: "pages/security/user-operation",
actorEnquiry: "pages/security/actor-enquiry",
// 底部导航 tab 页(首页复用 dashboard):先各建一个简单测试页占位
fileTab: "pages/tab/file",
mediaTab: "pages/tab/media",
shareTab: "pages/tab/share",
mineTab: "pages/tab/mine",
} as const;
export type PageName = keyof typeof PAGE_PATHS;
@@ -63,8 +68,28 @@ export const PAGE_PERMISSIONS: Partial<Record<PageName, PermissionNames>> = {
userEnquiry: "UserEnquiry",
userOperation: "UserOperation",
actorEnquiry: "SecurityActor",
// 底部导航 5 个 tab:真实权限尚未定义,按当前要求统一用 UserOperation 占位。
// 注意 dashboard 也被登记后,登录落地页(首页)会开始按 UserOperation 做路由级校验;
// 路由级是 fail-open(档案缺失/为空时放行),真正的边界仍在后端。
dashboard: "UserOperation",
fileTab: "UserOperation",
mediaTab: "UserOperation",
shareTab: "UserOperation",
mineTab: "UserOperation",
};
/**
* 底部导航的 tab 顺序,与 stores/tabbar 的 tabbarList 逐项按角标对齐。
* 首页复用 dashboard;app-tabbar 靠它把「点击下标 -> 目标页」与「当前路由 -> 高亮下标」对上。
*/
export const TAB_PAGES: PageName[] = [
"dashboard",
"fileTab",
"mediaTab",
"shareTab",
"mineTab",
];
/** 带前导斜杠的真实路径,供 uni.* API 之外的路径比较、日志使用 */
export function toRealPath(name: PageName): string {
return `/${PAGE_PATHS[name]}`;
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+64 -13
View File
@@ -1,37 +1,88 @@
import type { TabbarItem } from "uview-pro/types/global";
import { defineStore } from "pinia";
import { ref } from "vue";
/**
* 底部导航的静态配置:只放"结构"(图标 + i18n 键 + 是否凸起),
* 文字不在这里写死——由 components/app-tabbar 按当前语言 t(langKey) 翻译,
* 这样切换语言时 tab 文案会跟着变(store 本身对 locale 无感知)。
*/
export interface TabbarSeed {
langKey: string;
iconPath: string;
selectedIconPath: string;
/** 中心凸起大按钮;还需 u-tabbar 父级 midButton 也为 true 才生效 */
midButton?: boolean;
}
export const useTabbarStore = defineStore(
"tabbar",
() => {
const activeIndex = ref(0);
// 默认空列表:脚手架演示 tab(首页/关于)已随演示页一并移除。
// 后续底部导航项由旧项目移植的菜单数据(/SecurityFunction/FindMenus)驱动,
// 在此按端需要填充,或整体改造成 pages.json 原生 tabBar。
const tabbarList = ref<TabbarItem[]>([]);
// 顺序必须与 routes.ts 的 TAB_PAGES 逐项对齐(首页/文件/多媒体/共享/我的)。
// 刻意不写 pagePath:u-tabbar 一旦拿到 pagePath 就会调 uni.switchTab,而本项目没有原生
// tabBar,switchTab 会 fail。跳转统一由 app-tabbar 的 @change 用 reLaunch 完成,
// 高亮由当前页面路由反推下标(见 app-tabbar 的 onMounted)。
const tabs = ref<TabbarSeed[]>([
{
langKey: "TabBar.home",
iconPath: "home",
selectedIconPath: "home-fill",
},
{
langKey: "TabBar.file",
iconPath: "file-text",
selectedIconPath: "file-text-fill",
},
{
langKey: "TabBar.media",
// 中心凸起用自定义圆标图片(含 "/" → u-icon 走 <image> 渲染),非字体图标
iconPath: "/static/tabbar/media.png",
selectedIconPath: "/static/tabbar/media.png",
midButton: true,
},
{
langKey: "TabBar.share",
iconPath: "share",
selectedIconPath: "share-fill",
},
{
langKey: "TabBar.mine",
iconPath: "account",
selectedIconPath: "account-fill",
},
]);
// 各 tab 角标数字(下标 -> count),0/缺省表示不显示。
// 共享先挂占位值 34 以验证角标渲染,接入真实待办数量后由 setBadge 覆盖(下标 3 = 共享)。
const badges = ref<Record<number, number>>({ 3: 0 });
const setActiveIndex = (index: number) => {
activeIndex.value = index;
};
const updateBadge = (index: number, count: number) => {
tabbarList.value[index].count = count;
const setBadge = (index: number, count: number) => {
// 整体替换以触发响应式(改对象属性 Vue3 也能追踪,但替换更稳、便于持久化裁剪)
badges.value = { ...badges.value, [index]: count };
};
const updateIsDot = (index: number, isDot: boolean) => {
tabbarList.value[index].isDot = isDot;
const clearBadge = (index: number) => {
const next = { ...badges.value };
delete next[index];
badges.value = next;
};
return {
activeIndex,
tabbarList,
tabs,
badges,
setActiveIndex,
updateBadge,
updateIsDot,
setBadge,
clearBadge,
};
},
{
persist: true,
// 只持久化 activeIndex:tabs/badges 是配置与运行期数据,持久化会让改默认值后仍读到旧缓存
persist: {
paths: ["activeIndex"],
},
},
);