This commit is contained in:
owenchen committed 2026-10-14 14:48:29 +08:00
1 parent 8a2adb0a92
commit bd678b0112
22 files changed
+1968 -81

No files matched your search

@@ -69,15 +69,6 @@ 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 ?? "";
}
@@ -0,0 +1,203 @@
<script setup lang="ts">
/**
* 目录面包屑(仅子目录显示,页面自行 v-if):样式对齐网盘 App 路径条——白底描边圆角、细 chevron 分隔。
* 显示方式:放得下就全量展示;放不下时保留「根 + … + 尾部若干段」,被折叠的中间段收进「…」。
* 折叠不是 CSS 裁切:H5 渲染后实测内部宽度,逐段把折叠点向后推,直到不再溢出(非 H5 端按段数近似折叠)。
* 点「…」跳到被折叠段里最深的一层;点其它段跳到该层;当前目录(最后一段)点击无操作。
* 纯展示组件:只 emit('nav', 段下标),截断与重查由页面/composable 决定。
*/
import type { PropType } from "vue";
import type { PathNode } from "@/data/custom/fileBrowser";
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
const props = defineProps({
/** 从根到当前目录的完整路径栈 */
items: {
type: Array as PropType<PathNode[]>,
default: () => [],
},
});
const emit = defineEmits<{
(e: "nav", index: number): void;
}>();
const instance = getCurrentInstance();
/** 折叠点:下标 1..collapseFrom-1 的段被收进「…」;-1 表示全量展示 */
const collapseFrom = ref(-1);
/** 折到底(根+…+末段)仍放不下:允许末段压缩出省略号。
* 平时必须为 false——末段一旦可压缩,scrollWidth 就测不出真实溢出,折叠永远不会触发 */
const tailClamped = ref(false);
interface Segment {
node: PathNode;
index: number;
ellipsis: boolean;
}
/** 实际渲染的段序列:根 +(可选 …)+ 尾部 */
const shownSegments = computed<Segment[]>(() => {
const items = props.items;
if (collapseFrom.value < 1) {
return items.map((node, index) => ({ node, index, ellipsis: false }));
}
const segments: Segment[] = [{ node: items[0], index: 0, ellipsis: false }];
segments.push({
node: { value: "", displayName: "…" },
index: collapseFrom.value - 1,
ellipsis: true,
});
items.slice(collapseFrom.value).forEach((node, i) => {
segments.push({ node, index: collapseFrom.value + i, ellipsis: false });
});
return segments;
});
function onSegmentTap(seg: Segment): void {
// 当前目录段不用跳;「…」跳到被折叠段里最深的一层(其下标即 seg.index)
if (seg.index >= props.items.length - 1) {
return;
}
emit("nav", seg.index);
}
/** 是否仍在溢出:H5 实测滚动宽;其它端拿段数近似(超过 4 段继续折) */
function isOverflowing(): boolean {
// #ifdef H5
const root = instance?.proxy?.$el as HTMLElement | undefined;
const inner = root?.querySelector?.(".file-breadcrumb__rest-inner");
if (!inner) {
return false;
}
return inner.scrollWidth > inner.clientWidth + 1;
// #endif
// #ifndef H5
return props.items.length - Math.max(collapseFrom.value, 1) > 3;
// #endif
}
/** 重测折叠:先全展开,放不下就把折叠点一段一段往后推;折到底仍溢出才夹末段 */
async function remeasure(): Promise<void> {
collapseFrom.value = -1;
tailClamped.value = false;
if (props.items.length <= 1) {
return;
}
await nextTick();
let guard = props.items.length + 2;
while (isOverflowing() && guard-- > 0) {
// 折叠下限:根 + … + 末段(collapseFrom = length-1);两段路径无中间段可折
if (props.items.length > 2 && collapseFrom.value < props.items.length - 1) {
collapseFrom.value = collapseFrom.value < 2 ? 2 : collapseFrom.value + 1;
await nextTick();
continue;
}
tailClamped.value = true;
break;
}
}
// 路径内容(非引用)变化都要重测:下钻/返回/面包屑跳转/重命名都会改变段数或段宽
watch(() => props.items.map(node => `${node.value}|${node.displayName}`).join(">"), remeasure);
// 窗口尺寸变化(横竖屏切换、H5 拉窗口)也要重测:折叠是按实测宽度算的
onMounted(() => {
remeasure();
uni.onWindowResize(remeasure);
});
onUnmounted(() => {
uni.offWindowResize(remeasure);
});
</script>
<template>
<view v-if="items.length > 1" class="file-breadcrumb">
<text class="file-breadcrumb__root" @tap="onSegmentTap({ node: items[0], index: 0, ellipsis: false })">
{{ items[0].displayName }}
</text>
<view class="file-breadcrumb__rest">
<view class="file-breadcrumb__rest-inner">
<template v-for="seg in shownSegments.slice(1)" :key="`${seg.index}-${seg.node.value}`">
<u-icon class="file-breadcrumb__sep" name="arrow-right" size="22" color="#8f959e" />
<text class="file-breadcrumb__item"
:class="{ 'file-breadcrumb__item--ellipsis': seg.ellipsis, 'file-breadcrumb__item--clamped': tailClamped && seg.index === items.length - 1 }"
@tap="onSegmentTap(seg)">
{{ seg.node.displayName }}
</text>
</template>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
/* 面包屑:描边圈 + 浅浅的半透明白底(透出渐变底不发死白),分隔为细 chevron */
.file-breadcrumb {
display: flex;
align-items: center;
gap: 10rpx;
padding: 20rpx 28rpx;
border: 2rpx solid rgba(31, 35, 41, 0.08);
border-radius: 20rpx;
background-color: rgba(255, 255, 255, 0.45);
white-space: nowrap;
}
.file-breadcrumb__root {
flex-shrink: 0;
font-size: 28rpx;
color: #2b8fff;
}
.file-breadcrumb__rest {
flex: 1;
min-width: 0;
overflow: hidden;
}
/* 左起排列(对齐截图:短路径贴左,放不下才折叠出 …);测量溢出靠 scrollWidth 对比 */
.file-breadcrumb__rest-inner {
display: flex;
align-items: center;
white-space: nowrap;
}
.file-breadcrumb__item {
flex-shrink: 0;
font-size: 28rpx;
color: #2b8fff;
white-space: nowrap;
/* uni-text 内部 span 默认 pre-line,会让 nowrap/ellipsis 失效,须穿透覆盖 */
:deep(span) {
white-space: nowrap;
}
&--ellipsis {
padding: 0 4rpx;
color: #2b8fff;
}
/* 折到底仍溢出时的末段兜底:flex-shrink+min-width:0 让末段收缩进可视框,
* 内部 span 转 block 吃满收缩后的宽度出 CSS 省略号 */
&--clamped {
flex-shrink: 1;
min-width: 0;
max-width: 100%;
:deep(span) {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.file-breadcrumb__sep {
flex-shrink: 0;
margin: 0 6rpx;
}
</style>
@@ -0,0 +1,269 @@
<script setup lang="ts">
/**
* 文件网格列表:卡片展示文件夹图标 / 文件类型图标、名称、元数据(文件大小 + 修改时间,
* 文件夹只有时间、不显示用户),右上角三点「更多」。viewMode 支持 grid(三列卡片)与 list(单行列表)。
* 多选模式(selectMode)下点击卡片改为勾选/取消;数据、选中集均由页面持有,这里只渲染与上报。
*/
import type { PropType } from "vue";
import type { FileItem, FileKind } from "@/data/custom/fileBrowser";
const props = defineProps({
/** 已过滤排序好的条目,组件按原顺序渲染 */
files: {
type: Array as PropType<FileItem[]>,
default: () => [],
},
/** 排布模式:grid 九宫格 / list 列表 */
viewMode: {
type: String as PropType<"grid" | "list">,
default: "grid",
},
/** 是否处于多选模式 */
selectMode: {
type: Boolean,
default: false,
},
/** 多选模式下已勾选的 id 集合 */
selectedIds: {
type: Array as PropType<number[]>,
default: () => [],
},
/** 空列表提示文案(页面翻译好传入) */
emptyText: {
type: String,
default: "",
},
});
const emit = defineEmits<{
/** 点开条目(非多选模式的单击) */
(e: "open", file: FileItem): void;
/** 卡片右上角「更多」 */
(e: "more", file: FileItem): void;
/** 多选模式下勾选状态变化 */
(e: "toggle-select", id: number): void;
}>();
// 文件类型 -> 图标与配色(文件夹走纯 CSS 造型,不用字体图标)
const kindVisual: Record<Exclude<FileKind, "folder">, { icon: string; color: string }> = {
image: { icon: "photo-fill", color: "#0ea5e9" },
video: { icon: "play-right-fill", color: "#8b5cf6" },
document: { icon: "file-text-fill", color: "#3b82f6" },
audio: { icon: "volume-up", color: "#f59e0b" },
};
function visualOf(kind: FileKind): { icon: string; color: string } | null {
return kind === "folder" ? null : kindVisual[kind];
}
function isSelected(file: FileItem): boolean {
return props.selectedIds.includes(file.id);
}
function onCardTap(file: FileItem): void {
if (props.selectMode) {
emit("toggle-select", file.id);
return;
}
emit("open", file);
}
</script>
<template>
<view class="file-grid-list" :class="{ 'file-grid-list--list': viewMode === 'list' }">
<view v-if="!files.length" class="file-grid-list__empty">
<text class="file-grid-list__empty-text">
{{ emptyText }}
</text>
</view>
<view v-for="file in files" :key="file.id" class="fgl-card"
:class="{ 'fgl-card--selected': selectMode && isSelected(file) }">
<view class="fgl-card__more" @tap.stop="emit('more', file)">
<u-icon name="more-dot-fill" size="30" color="#b8bcc4" />
</view>
<view class="fgl-card__body" @tap="onCardTap(file)">
<!-- 文件夹:纯 CSS 造型(图标库没有 folder),主体 + 左上页签 -->
<view v-if="file.kind === 'folder'" class="fgl-card__folder">
<view class="fgl-card__folder-tab" />
</view>
<u-icon v-else :name="visualOf(file.kind)!.icon" size="88" :color="visualOf(file.kind)!.color" />
<view class="fgl-card__info">
<text class="fgl-card__name">
{{ file.name }}
</text>
<view class="fgl-card__meta">
<text v-if="file.sizeText" class="fgl-card__meta-text">
{{ file.sizeText }}
</text>
<text class="fgl-card__meta-text">
{{ file.modifiedAt }}
</text>
</view>
</view>
<view v-if="selectMode" class="fgl-card__check">
<u-icon :name="isSelected(file) ? 'checkmark-circle-fill' : 'checkmark-circle'" size="40"
:color="isSelected(file) ? '#2b8fff' : '#c0c4cc'" />
</view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.file-grid-list {
display: flex;
flex-wrap: wrap;
padding: 16rpx 20rpx 40rpx;
}
.file-grid-list__empty {
width: 100%;
padding: 120rpx 0;
text-align: center;
}
.file-grid-list__empty-text {
font-size: 26rpx;
color: #9aa0a6;
}
.fgl-card {
position: relative;
width: calc(100% / 3 - 16rpx);
margin: 8rpx;
padding-bottom: 20rpx;
border-radius: 20rpx;
background-color: #f7f8fa;
&--selected {
background-color: #eaf4ff;
}
}
.fgl-card__more {
position: absolute;
top: 12rpx;
right: 12rpx;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 52rpx;
height: 44rpx;
border-radius: 12rpx;
background-color: rgba(31, 35, 41, 0.05);
}
.fgl-card__body {
display: flex;
flex-direction: column;
align-items: center;
padding: 36rpx 12rpx 0;
}
/* ---- 纯 CSS 文件夹:蓝色渐变圆角主体 + 左上页签 ---- */
.fgl-card__folder {
position: relative;
width: 132rpx;
height: 104rpx;
margin: 14rpx 0;
border-radius: 16rpx;
background: linear-gradient(135deg, #6ec2ff 0%, #2b8fff 100%);
box-shadow: 0 10rpx 20rpx rgba(43, 143, 255, 0.25);
}
.fgl-card__folder-tab {
position: absolute;
top: -16rpx;
left: 14rpx;
width: 56rpx;
height: 24rpx;
border-radius: 10rpx 10rpx 0 0;
background: linear-gradient(135deg, #6ec2ff 0%, #4aa6ff 100%);
}
.fgl-card__info {
width: 100%;
padding: 0 12rpx;
}
.fgl-card__name {
display: block;
overflow: hidden;
font-size: 28rpx;
font-weight: 500;
color: #1f2329;
text-overflow: ellipsis;
white-space: nowrap;
}
.fgl-card__meta {
display: flex;
gap: 8rpx;
margin-top: 6rpx;
overflow: hidden;
}
.fgl-card__meta-text {
flex-shrink: 0;
max-width: 100%;
overflow: hidden;
font-size: 22rpx;
color: #9aa0a6;
text-overflow: ellipsis;
white-space: nowrap;
}
.fgl-card__check {
position: absolute;
right: 12rpx;
bottom: 16rpx;
}
/* ---- 列表模式:单行排布,覆盖网格默认样式 ---- */
.file-grid-list--list {
flex-direction: column;
flex-wrap: nowrap;
padding: 8rpx 24rpx 40rpx;
}
.file-grid-list--list .fgl-card {
width: 100%;
margin: 0 0 16rpx;
padding-bottom: 0;
}
.file-grid-list--list .fgl-card__more {
top: 50%;
transform: translateY(-50%);
background-color: transparent;
}
.file-grid-list--list .fgl-card__body {
flex-direction: row;
padding: 24rpx 64rpx 24rpx 24rpx;
}
.file-grid-list--list .fgl-card__folder {
width: 72rpx;
height: 56rpx;
margin: 0 24rpx 0 0;
border-radius: 10rpx;
}
.file-grid-list--list .fgl-card__folder-tab {
top: -10rpx;
left: 8rpx;
width: 30rpx;
height: 14rpx;
border-radius: 6rpx 6rpx 0 0;
}
.file-grid-list--list .fgl-card__info {
flex: 1;
min-width: 0;
}
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
/**
* 分类标签筛选:横向标签(全部/图片/视频/文档/音频)+ 右侧「列表视图切换」「多选」按钮。
* 标签内容由页面传入(翻译在页面做);当前选中、视图模式、多选态都是外部状态,
* 组件只上报用户意图(change / toggle-view / toggle-select),自身不改任何东西。
*/
import type { PropType } from "vue";
import type { FileTabItem } from "@/data/custom/fileBrowser";
defineProps({
/** 分类标签列表 */
tabs: {
type: Array as PropType<FileTabItem[]>,
default: () => [],
},
/** 当前选中的分类 key */
active: {
type: String,
default: "all",
},
/** 当前视图:grid 九宫格 / list 列表 */
viewMode: {
type: String as PropType<"grid" | "list">,
default: "list",
},
/** 是否处于多选模式 */
selectMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits<{
(e: "change", key: FileTabItem["key"]): void;
(e: "sort"): void;
(e: "toggle-view"): void;
(e: "toggle-select"): void;
}>();
</script>
<template>
<view class="file-tab-filter">
<scroll-view class="file-tab-filter__scroll" scroll-x :show-scrollbar="false">
<view class="file-tab-filter__tabs">
<text v-for="tab in tabs" :key="tab.key" class="file-tab-filter__tab"
:class="{ 'file-tab-filter__tab--active': tab.key === active }" @tap="emit('change', tab.key)">
{{ tab.label }}
</text>
</view>
</scroll-view>
<view class="file-tab-filter__tools">
<!-- 排序(从页面导航栏迁来,倒数第3位):level 阶梯横线,比原 order 更有排序感 -->
<view class="file-tab-filter__tool" @tap="emit('sort')">
<u-icon name="level" size="36" color="#1f2329" />
</view>
<!-- 显示的是「点后会切到什么」:网格态给列表图标,列表态给网格图标 -->
<view class="file-tab-filter__tool" @tap="emit('toggle-view')">
<u-icon :name="viewMode === 'grid' ? 'list' : 'grid-fill'" size="36" color="#1f2329" />
</view>
<view class="file-tab-filter__tool" @tap="emit('toggle-select')">
<u-icon name="checkmark-circle" size="36" :color="selectMode ? '#2b8fff' : '#1f2329'" />
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
/* 分类条卡片:与面包屑/搜索框同风格——描边圈 + 浅半透明白底,透出渐变底 */
.file-tab-filter {
display: flex;
align-items: center;
margin: 20rpx 24rpx 0;
padding: 8rpx 16rpx;
border: 2rpx solid rgba(31, 35, 41, 0.08);
border-radius: 24rpx;
background-color: rgba(255, 255, 255, 0.45);
}
.file-tab-filter__scroll {
flex: 1;
min-width: 0;
white-space: nowrap;
}
.file-tab-filter__tabs {
display: inline-flex;
align-items: center;
gap: 16rpx;
padding-left: 20rpx;
}
.file-tab-filter__tab {
padding: 8rpx 22rpx;
border-radius: 999rpx;
background-color: #f2f3f5;
font-size: 24rpx;
color: #5c626b;
flex-shrink: 0;
&--active {
background-color: #1f2329;
color: #ffffff;
font-weight: 600;
}
}
.file-tab-filter__tools {
display: flex;
align-items: center;
gap: 8rpx;
flex-shrink: 0;
}
.file-tab-filter__tool {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
}
</style>
@@ -0,0 +1,39 @@
<script setup lang="ts">
/**
* 悬浮操作按钮:右下角圆形蓝色渐变「+」,固定在底部导航之上。
* 只做外观与点击上报;新建/上传等动作语义由页面处理。
*/
defineProps({
/** 额外底部偏移(rpx 数值),供不同页面避开各自的底部元素 */
bottomOffset: {
type: Number,
default: 0,
},
});
const emit = defineEmits<{
(e: "click"): void;
}>();
</script>
<template>
<view class="float-add-btn" :style="{ bottom: `${180 + bottomOffset}rpx` }" @tap="emit('click')">
<u-icon name="plus" size="52" color="#ffffff" />
</view>
</template>
<style lang="scss" scoped>
.float-add-btn {
position: fixed;
right: 40rpx;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6ec2ff 0%, #2b8fff 100%);
box-shadow: 0 12rpx 32rpx rgba(43, 143, 255, 0.4);
}
</style>
@@ -0,0 +1,120 @@
<script setup lang="ts">
/**
* 文件页顶部导航栏:「返回箭头 + 居中标题 + 右侧两个显隐开关」。
* 根目录不显示返回(showBack=false);标题两侧等宽占位保证真居中。
* 右侧 search/grid 两个图标分别切换页面里搜索框与快捷入口区的显隐,
* 点亮(蓝)= 对应区域显示,置灰 = 隐藏;排序按钮在 file-tab-filter 工具区。
* 纯展示组件:不自己做导航与显隐逻辑,按钮语义一律 emit 给页面决定。
* 顶栏配色跟随截图(白底黑字),不并入 app-page 的绿色 u-navbar,故页面用 hide-nav 让位。
*/
const props = defineProps({
/** 页面标题(页面用 t() 翻译好后传进来) */
title: {
type: String,
required: true,
},
/** 是否显示返回箭头:仅进入子目录后显示 */
showBack: {
type: Boolean,
default: false,
},
/** 搜索框当前是否可见(决定开关图标高亮态) */
searchOn: {
type: Boolean,
default: true,
},
/** 快捷入口区当前是否可见(决定开关图标高亮态) */
quickOn: {
type: Boolean,
default: true,
},
});
const emit = defineEmits<{
(e: "back"): void;
(e: "toggle-search"): void;
(e: "toggle-quick"): void;
}>();
</script>
<template>
<view class="page-navbar">
<view class="page-navbar__side page-navbar__side--left">
<view v-if="props.showBack" class="page-navbar__back" @tap="emit('back')">
<u-icon name="arrow-left" size="42" color="#1f2329" />
</view>
</view>
<text class="page-navbar__title">
{{ props.title }}
</text>
<view class="page-navbar__side page-navbar__side--right">
<!-- 切换搜索框显隐 -->
<view class="page-navbar__btn" @tap="emit('toggle-search')">
<u-icon name="search" size="38" :color="props.searchOn ? '#2b8fff' : '#8f959e'" />
</view>
<!-- 切换快捷入口区显隐 -->
<view class="page-navbar__btn" @tap="emit('toggle-quick')">
<u-icon name="grid" size="38" :color="props.quickOn ? '#2b8fff' : '#8f959e'" />
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.page-navbar {
position: sticky;
top: 0;
z-index: 9;
display: flex;
align-items: center;
padding: 16rpx 24rpx;
/* 透明顶栏:透出页面的浅蓝→白渐变底 */
background-color: transparent;
}
/* 左右等宽占位(容纳右侧两个按钮):任何状态下标题都严格居中 */
.page-navbar__side {
display: flex;
align-items: center;
gap: 6rpx;
width: 148rpx;
min-height: 64rpx;
flex-shrink: 0;
}
.page-navbar__side--left {
justify-content: flex-start;
}
.page-navbar__side--right {
justify-content: flex-end;
}
.page-navbar__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
/* 显隐开关按钮:与 file-tab-filter 工具图标同尺寸,保持一行工具观感 */
.page-navbar__btn {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
}
.page-navbar__title {
flex: 1;
text-align: center;
font-size: 38rpx;
font-weight: 600;
color: #1f2329;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,77 @@
<script setup lang="ts">
/**
* 快捷功能入口:横向滚动的图标按钮组(最近访问 / 收藏 / 已下载 / 团队文件 / 共享文件)。
* 单项内容(图标名 + 渐变色 + 文案)全部由页面通过 items 传入,组件只渲染与派发点击。
*/
import type { PropType } from "vue";
import type { QuickEntryItem } from "@/data/custom/fileBrowser";
defineProps({
/** 入口列表,顺序即展示顺序 */
items: {
type: Array as PropType<QuickEntryItem[]>,
default: () => [],
},
});
const emit = defineEmits<{
(e: "click", key: string): void;
}>();
</script>
<template>
<scroll-view class="quick-entry" scroll-x :show-scrollbar="false">
<view class="quick-entry__row">
<view v-for="item in items" :key="item.key" class="quick-entry__item" @tap="emit('click', item.key)">
<view class="quick-entry__tile"
:style="{ backgroundImage: `linear-gradient(135deg, ${item.bgFrom}, ${item.bgTo})` }">
<u-icon :name="item.icon" size="44" color="#ffffff" />
</view>
<text class="quick-entry__label">
{{ item.label }}
</text>
</view>
</view>
</scroll-view>
</template>
<style lang="scss" scoped>
/* 透明快捷入口:透出页面渐变底;左右留边仍由页面外层包装负责
(scroll-view 根节点固定 100% 宽,自身加 margin 会右侧溢出被裁,实测踩过) */
.quick-entry {
padding: 28rpx 0 24rpx;
border-radius: 24rpx;
background-color: transparent;
white-space: nowrap;
}
.quick-entry__row {
display: inline-flex;
align-items: flex-start;
padding: 0 32rpx;
}
.quick-entry__item {
display: flex;
flex-direction: column;
align-items: center;
width: 128rpx;
flex-shrink: 0;
}
.quick-entry__tile {
display: flex;
align-items: center;
justify-content: center;
width: 96rpx;
height: 96rpx;
border-radius: 28rpx;
box-shadow: 0 8rpx 16rpx rgba(31, 35, 41, 0.08);
}
.quick-entry__label {
margin-top: 14rpx;
font-size: 26rpx;
color: #1f2329;
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
/**
* 搜索框:uView Pro 的 u-input + 放大镜前缀图标,圆角浅灰底(对齐截图样式)。
* 输入即回传(配合 v-model 语义),键盘确认派发 search 事件;
* 注意 uview-pro 的 u-input 只派发 input/update:modelValue,没有 change 事件,
* 绑错 @change 会导致关键字永不回传(实测踩过)。组件不碰接口,过滤时机由页面决定。
*/
import { computed } from "vue";
const props = defineProps({
/** 关键字(v-model 语义) */
modelValue: {
type: String,
default: "",
},
/** 占位文案(页面翻译好传入) */
placeholder: {
type: String,
default: "",
},
});
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
(e: "search", value: string): void;
}>();
// u-input 默认白底带边框,这里压成透明让它融进外层浅灰圆角容器
const inputStyle = computed(() => ({
backgroundColor: "transparent",
}));
function onInput(value: string): void {
emit("update:modelValue", value);
}
function onConfirm(value: string): void {
emit("search", value ?? props.modelValue);
}
function onClear(): void {
emit("update:modelValue", "");
}
</script>
<template>
<view class="search-input">
<view class="search-input__box">
<u-icon name="search" size="34" color="#9aa0a6" />
<view class="search-input__field">
<u-input :model-value="modelValue" :placeholder="placeholder" :border="false" clearable
:custom-style="inputStyle" placeholder-style="color: #9aa0a6" confirm-type="search" @input="onInput"
@confirm="onConfirm" @clear="onClear" />
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
/* 分区卡片风格:外层透明露渐变底,胶囊内部浅白色、留描边定形 */
.search-input {
padding: 2rpx 24rpx 0;
}
.search-input__box {
display: flex;
align-items: center;
gap: 16rpx;
padding: 10rpx 28rpx;
border: 2rpx solid rgba(31, 35, 41, 0.08);
border-radius: 399rpx;
background-color: rgba(255, 255, 255, 0.55);
}
.search-input__field {
flex: 1;
}
</style>
+2
View File
@@ -2,4 +2,6 @@ export * from "./useAdminList";
export * from "./useAdminMenu";
export * from "./useAdminPick";
export * from "./useAuthGuard";
export * from "./useDmsFiles";
export * from "./useLang";
export * from "./useThemeGradient";
@@ -0,0 +1,286 @@
/**
* 文件页数据源:复刻旧 WebVue DmsFileEnquiry.vue 的调用链(D:\Docker\WebVue\src\views\master)
*
* 1. FindDmsFolders() -> SelectOption[]:rps[0] 是空占位项,rps[1] 才是根目录「我的网盘」,
* 旧页面 rootPath = rps[1].BindValue 的取值习惯原样保留;
* 2. FindBySearchCriteria(criteria):Filter = [FilePath single 当前目录],
* 路径为空则不挂 FilePath 过滤查全量(旧 onSearchByFilePath 对空 rootPath 的分支);
* 有关键字再追加 [OrgFileName text contain](旧 onSearchByName 的两段过滤);
* limit 给 999999 一次拉全(旧页面同值,目录场景不分页);
* 3. 结果文件夹置顶(旧页面 orderBy IsDirectoryIndc desc)。
*
* 目录导航:点文件夹把该记录的 FileName 当作新的 FilePath 重查(旧 onOpenFolder ->
* onChangeFolder),pathStack 记录面包屑供返回;类型判定优先看后端 FileCategory
* (P/V/A/D/L,见 FileTypePolicy 常量),缺省时按扩展名兜底(fileTypePolicy)。
*/
import type { DmsFileUploadViewDisplayData } from "@/data/auto/dmsFileUploadViewDisplayData";
import type { FileItem, FileKind, PathNode } from "@/data/custom/fileBrowser";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { DmsFileSearchCriteria } from "@/data/auto/dmsFileSearchCriteria";
import { DmsFileUploadViewDisplayData as UploadView } from "@/data/auto/dmsFileUploadViewDisplayData";
import { SearchFilter } from "@/data/auto/searchFilter";
import fileTypePolicy, { FileTypePolicy } from "@/policy/custom/fileTypePolicy";
import dmsFileService from "@/service/master/dmsFileService";
/** 目录场景一次拉全,与旧页面 pagingParams.pageSize 同值 */
const FIND_LIMIT = 999999;
/** FileCategory 编码 -> 页面分类;缺省或未知编码走扩展名兜底 */
function kindOf(record: DmsFileUploadViewDisplayData): FileKind {
if (record.IsDirectoryIndc === "Y") {
return "folder";
}
switch (record.FileCategory) {
case FileTypePolicy.PHOTO:
return "image";
case FileTypePolicy.VIDEO:
return "video";
case FileTypePolicy.AUDIO:
return "audio";
case FileTypePolicy.DOCUMENT:
case FileTypePolicy.LYRIC:
return "document";
}
const key = record.OrderKey || record.OrgFileName || "";
if (fileTypePolicy.isImage(key)) {
return "image";
}
if (fileTypePolicy.isVideo(key)) {
return "video";
}
if (fileTypePolicy.isAudio(key)) {
return "audio";
}
return "document";
}
/** 兼容 "2026-05-13 08:17:00" 这类 iOS Date 解析失败的格式 */
function parseTime(value: Date | string | undefined): number {
if (!value) {
return 0;
}
if (value instanceof Date) {
return value.getTime();
}
const normalized = value
.replace(/-/g, "/")
.replace(/\.\d+Z?$/, "")
.replace(/T/, " ")
.replace(/Z$/, "");
const time = new Date(normalized).getTime();
return Number.isNaN(time) ? 0 : time;
}
/** 字节数 -> 可读大小文案;0/缺省返回空串(文件夹不显示大小) */
function formatSize(bytes: number | undefined): string {
if (!bytes || bytes <= 0) {
return "";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i += 1;
}
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
}
export function useDmsFiles() {
const { t } = useLocale();
const files = ref<FileItem[]>([]);
/** FileId -> 原始记录:预览/下载/删除等后续动作要拿全字段 */
const records = ref(new Map<number, DmsFileUploadViewDisplayData>());
const loading = ref(false);
const errorText = ref("");
const rootPath = ref("");
const currPath = ref("");
const pathStack = ref<PathNode[]>([]);
/** 是否在根目录:决定导航栏返回箭头是「退目录」还是「退页面」 */
const atRoot = computed(() => pathStack.value.length <= 1);
function toFileItem(record: DmsFileUploadViewDisplayData): FileItem {
const time = parseTime(record.CreatedDatetime);
const date = time ? new Date(time) : null;
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
let modifiedAt = "";
if (date) {
// 今天的条目显示「今天 HH:mm」,其余显示日期(网盘习惯);if/else 写法防编辑器折行
if (date.toDateString() === now.toDateString()) {
modifiedAt = `${t("FilePage.todayLabel")} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
} else {
modifiedAt = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
}
return {
id: record.FileId,
name: record.OrgFileName || record.FileName,
kind: kindOf(record),
// 卡片元数据不显示用户:文件夹 sizeText 为空只剩时间,文件显示大小+时间
// FileSize 单位是 KB(旧系统 DmsFileEnquiry 下载缓冲 (FileSize+1)*1024 字节佐证),先换算成字节
sizeText: formatSize(
record.FileSize ? record.FileSize * 1024 : undefined,
),
modifiedAt,
sortTime: time,
path: record.FileName,
};
}
function buildCriteria(path: string, keyword: string): DmsFileSearchCriteria {
const criteria = new DmsFileSearchCriteria();
const filters: SearchFilter[] = [];
// 旧 onSearchByFilePath 语义:路径为空(本地库 FindDmsFolders 只回一条空 BindValue 时)
// 不挂 FilePath 过滤、照常查询全量,而不是当错误处理
if (path) {
const pathFilter = new SearchFilter();
pathFilter.Column = UploadView.DB_FILE_PATH;
pathFilter.Type = "SingleSelection";
pathFilter.Operator = "single";
pathFilter.Value = path;
filters.push(pathFilter);
}
if (keyword.trim()) {
const nameFilter = new SearchFilter();
nameFilter.Column = UploadView.DB_ORG_FILE_NAME;
nameFilter.Type = "text";
nameFilter.Operator = "contain";
nameFilter.Value = keyword.trim();
filters.push(nameFilter);
}
criteria.Filter = filters;
criteria.limit = FIND_LIMIT;
criteria.page = 1;
criteria.GridColumns = [];
return criteria;
}
/** 查一个目录;path 为空查全量(旧 onSearchByFilePath 空路径语义),keyword 非空时服务端按名称 contain 过滤 */
async function loadFolder(path: string, keyword = ""): Promise<void> {
loading.value = true;
errorText.value = "";
try {
const data = await dmsFileService.FindBySearchCriteria(
buildCriteria(path, keyword),
);
const rows = data?.Datas ?? [];
// 文件夹置顶(旧页面 orderBy IsDirectoryIndc desc),组内保持后端顺序;
// for-of 而非单参数箭头:编辑器格式化会反复加回 (r) => 括号,与 arrow-parens 对撞
const dirs: DmsFileUploadViewDisplayData[] = [];
const others: DmsFileUploadViewDisplayData[] = [];
for (const row of rows) {
if (row.IsDirectoryIndc === "Y") {
dirs.push(row);
} else {
others.push(row);
}
}
const sorted = [...dirs, ...others];
files.value = sorted.map(toFileItem);
const nextRecords = new Map<number, DmsFileUploadViewDisplayData>();
for (const row of sorted) {
nextRecords.set(row.FileId, row);
}
records.value = nextRecords;
currPath.value = path;
} catch (error: any) {
// 错误提示已由 http.interceptor 统一 toast,这里只留页面内可见的失败态
files.value = [];
records.value = new Map();
errorText.value = error?.message || t("FilePage.loadFailed");
} finally {
loading.value = false;
}
}
/** 初始化:拉目录树拿根路径,再查根目录(旧 onMounted -> initBindOption -> onSearchByFilePath) */
async function init(): Promise<void> {
loading.value = true;
errorText.value = "";
try {
const options = (await dmsFileService.FindDmsFolders()) ?? [];
// rps[0] 是空占位项,rps[1] 才是根目录「我的网盘」,与旧页面取值一致;
// 本地库实测只回一条 [{DisplayName:"我的网盘", BindValue:""}]——旧系统对此不视为错误:
// 根面包屑无条件放入(value 可为空),空根路径走 loadFolder("") 查全量
const root = options.length > 1 ? options[1] : options[0];
rootPath.value = root?.BindValue ?? "";
pathStack.value = [
{
value: rootPath.value,
displayName: root?.DisplayName || t("FilePage.myCloud"),
},
];
await loadFolder(rootPath.value);
} catch (error: any) {
errorText.value = error?.message || t("FilePage.loadFailed");
} finally {
loading.value = false;
}
}
/** 点文件夹下钻;非文件夹返回 false 交给页面处理打开动作 */
async function openFolder(file: FileItem): Promise<boolean> {
if (file.kind !== "folder" || !file.path) {
return false;
}
pathStack.value.push({ value: file.path, displayName: file.name });
await loadFolder(file.path);
return true;
}
/** 返回上一级目录;已在根目录返回 false 交给页面处理退出 */
async function back(): Promise<boolean> {
if (pathStack.value.length <= 1) {
return false;
}
pathStack.value.pop();
const prev = pathStack.value[pathStack.value.length - 1];
await loadFolder(prev.value);
return true;
}
/** 服务端按名称搜索当前目录(关键字清空即重查全目录) */
async function search(keyword: string): Promise<void> {
await loadFolder(currPath.value, keyword);
}
/** 面包屑跳转:截断到第 index 层并查该层;已在该层则忽略 */
async function goTo(index: number): Promise<void> {
if (index < 0 || index >= pathStack.value.length - 1) {
return;
}
pathStack.value = pathStack.value.slice(0, index + 1);
await loadFolder(pathStack.value[index].value);
}
/** 失败重试:还停在当前目录就重查目录,初始化失败则整个链路重来 */
async function retry(): Promise<void> {
if (currPath.value) {
await loadFolder(currPath.value);
return;
}
await init();
}
return {
files,
records,
loading,
errorText,
pathStack,
atRoot,
init,
openFolder,
back,
goTo,
search,
retry,
};
}
@@ -0,0 +1,48 @@
import { useTheme } from "uview-pro";
import { computed } from "vue";
/**
* 主题渐变 composable:把 uView Pro 当前主题的 primary 派生成页面底渐变。
* 背景:文件页等页面头部各块已透明化,页面底用「主题色混白」的三段纵向渐变
* 承接主题切换(mine 页 setTheme 后无需刷新即联动);混白而非固定色值,
* 保证任意主题下都是同色系浅底,白卡与文字对比天然保持。
* 用法:const { themeBackground } = useThemeGradient();
* <view :style="{ background: themeBackground }">
* 浓度档可经 options 覆盖(如弹窗想更淡:{ top: 0.15, ... }),默认 25%/14%/5%。
*/
/** 渐变三段的主题色浓度(0-1,越大越浓),按 0% / 40% / 100% 三个停靠点铺 */
export interface ThemeGradientOptions {
/** 顶部浓度,默认 0.25 */
top?: number;
/** 40% 高度处浓度,默认 0.14 */
middle?: number;
/** 底部浓度,默认 0.05 */
bottom?: number;
}
/** uView 出厂蓝,仅作 currentTheme 尚未就绪时的兜底(正常初始化后不会被读到) */
const FALLBACK_PRIMARY = "#2979ff";
/** 主题 primary 按比例混入白色(ratio 为主题色占比),得到同色系浅色 rgb() */
export function mixWithWhite(hex: string, ratio: number): string {
const n = hex.replace("#", "");
const channel = (start: number) =>
Number.parseInt(n.slice(start, start + 2), 16);
const mix = (c: number) => Math.round(c * ratio + 255 * (1 - ratio));
return `rgb(${mix(channel(0))}, ${mix(channel(2))}, ${mix(channel(4))})`;
}
export function useThemeGradient(options: ThemeGradientOptions = {}) {
const { top = 0.25, middle = 0.14, bottom = 0.05 } = options;
// currentTheme 是包级单例 ref:任意页 setTheme,这里即时重算
const { currentTheme } = useTheme();
/** 页面底渐变(180deg 纵向),直接绑到内联 style 的 background */
const themeBackground = computed(() => {
const primary = currentTheme.value?.color?.primary ?? FALLBACK_PRIMARY;
return `linear-gradient(180deg, ${mixWithWhite(primary, top)} 0%, ${mixWithWhite(primary, middle)} 40%, ${mixWithWhite(primary, bottom)} 100%)`;
});
return { currentTheme, themeBackground };
}
@@ -0,0 +1,47 @@
/**
* 文件浏览页的数据契约:页面持有数据与状态,六个展示组件只吃 props。
* 展示字段(sizeText / modifiedAt)由页面格式化成最终文案,组件不再二次加工。
*/
/** 分类标签的取值:全部 / 图片 / 视频 / 文档 / 音频 */
export type FileCategory = "all" | "image" | "video" | "document" | "audio";
/** 单个条目的类型:四种文件 + 文件夹 */
export type FileKind = Exclude<FileCategory, "all"> | "folder";
export interface FileItem {
id: number;
name: string;
kind: FileKind;
/** 文件大小文案(如「2.9 MB」);文件夹为空串——卡片不显示大小也不显示用户 */
sizeText: string;
/** 修改时间展示文案(今天 / 具体日期),仅用于显示 */
modifiedAt: string;
/** 可排序的时间戳(毫秒):展示文案没法比大小,排序用它 */
sortTime: number;
/** DMS 记录的 FileName:文件夹点它下钻(当作下次查询的 FilePath),文件留作下载/预览键 */
path: string;
}
/** 快捷功能入口的单项:图标 + 渐变底色,页面给什么画什么 */
export interface QuickEntryItem {
key: string;
label: string;
icon: string;
bgFrom: string;
bgTo: string;
}
/** 分类标签单项 */
export interface FileTabItem {
key: FileCategory;
label: string;
}
/** 面包屑/目录栈单项:从根到当前目录 */
export interface PathNode {
/** 查询用的 FilePath 值 */
value: string;
/** 展示名(根为「我的网盘」,其余为文件夹 OrgFileName) */
displayName: string;
}
@@ -14,6 +14,41 @@
"share": "Share",
"mine": "Me"
},
"MinePage": {
"title": "Me",
"themeTitle": "Theme Color",
"themeHint": "Component colors update instantly on switch and the choice is remembered",
"darkTitle": "Dark Mode",
"darkAuto": "Follow System",
"darkLight": "Light",
"darkDark": "Dark",
"currentTheme": "Current Theme"
},
"FilePage": {
"title": "Files",
"searchPlaceholder": "Search",
"recent": "Recent",
"favorite": "Favorites",
"downloaded": "Downloaded",
"team": "Team Files",
"shared": "Shared",
"catAll": "All",
"catImage": "Images",
"catVideo": "Videos",
"catDocument": "Docs",
"catAudio": "Audio",
"emptyText": "No matching files",
"sortTimeDesc": "Newest first",
"sortTimeAsc": "Oldest first",
"sortName": "Sort by name",
"selectedCount": "{count} selected",
"todoTip": "Coming soon",
"todayLabel": "Today",
"myCloud": "My Cloud",
"loading": "Loading…",
"loadFailed": "Failed to load, please retry",
"retryBut": "Retry"
},
"AdminPage": {
"filterBut": "Filter",
"filterTitle": "Filter Conditions",
@@ -14,6 +14,41 @@
"share": "共享",
"mine": "我的"
},
"MinePage": {
"title": "我的",
"themeTitle": "主题颜色",
"themeHint": "切换后组件配色即时更新,选择会被记住",
"darkTitle": "深色模式",
"darkAuto": "跟随系统",
"darkLight": "浅色",
"darkDark": "深色",
"currentTheme": "当前主题"
},
"FilePage": {
"title": "文件",
"searchPlaceholder": "搜索",
"recent": "最近访问",
"favorite": "收藏",
"downloaded": "已下载",
"team": "团队文件",
"shared": "共享文件",
"catAll": "全部",
"catImage": "图片",
"catVideo": "视频",
"catDocument": "文档",
"catAudio": "音频",
"emptyText": "没有匹配的文件",
"sortTimeDesc": "按修改时间降序",
"sortTimeAsc": "按修改时间升序",
"sortName": "按名称排序",
"selectedCount": "已选 {count} 项",
"todoTip": "功能开发中,敬请期待",
"todayLabel": "今天",
"myCloud": "我的云盘",
"loading": "加载中…",
"loadFailed": "加载失败,请重试",
"retryBut": "重试"
},
"AdminPage": {
"filterBut": "筛选",
"filterTitle": "筛选条件",
+82
View File
@@ -0,0 +1,82 @@
# uView Pro 主题系统与渐变底接线
适用范围:`src/common/uview-pro.theme.ts`(主题定义)、`src/main.ts`(注册)、`src/composables/useThemeGradient.ts`(渐变派生)、`src/pages/tab/mine.vue`(切换入口)、文件页等使用主题渐变底的页面。
路由与权限见 [page-routing.md](./page-routing.md)、[permission.md](./permission.md);环境变量见 [environment.md](./environment.md)。
---
## 1. 主题从哪来:定义 → 注册 → 使用
- **定义**:`src/common/uview-pro.theme.ts` 导出主题数组,每项 `{ name, label, color }`;`color` 必含 `primary/error/warning/success/info`,`*Light/*Dark/*Disabled` 可缺(库有 `applyColorFallbacks` 自动派生)。当前四套:`purple 霞光紫 / green 清翠绿 / orange 暖阳橙 / dark 午夜蓝`。
- **注册**:`main.ts` 装插件时一次性传入,改默认主题或增删主题都只动这两个文件:
```text
app.use(uViewPro, {
theme: {
themes, // 来自 common/uview-pro.theme.ts
defaultTheme: "green", // 冷启动且 Storage 无记录时用它
defaultDarkMode: "light",
},
...
});
```
- **使用**:组件配色走 CSS 变量 `--u-type-primary`(注入在 `:root`),或 JS 里读 `useTheme().currentTheme`。
## 2. 切换与持久化:不要自建 store
```text
const { themes, currentTheme, setTheme, darkMode, setDarkMode, isInDarkMode } = useTheme();
setTheme("orange"); // 切换 + 写 Storage('uview-pro-theme')
setDarkMode("auto"); // 'auto' | 'light' | 'dark',写 Storage('uview-pro-dark-mode')
```
要点(均为库行为,实测确认):
- `useTheme()` 的 `currentTheme/darkMode` 是**包级单例 ref**:任何页面调用拿到的都是同一份,mine 页切主题、文件页渐变即时联动,**无需刷新、无需事件总线**;
- 持久化由库自动完成(Storage 键 `uview-pro-theme`、`uview-pro-dark-mode`),冷启动 `initTheme` 自动恢复,**页面层不要再包一层 Pinia store**;
- `themes` 列表 = **内置 defaultThemes 与自定义主题合并**(`config-provider.normalizeThemes` 的 `mergeThemes`),所以 mine 页会多渲染一个出厂「默认蓝 #2979ff」色点——这是库设计,不是 bug;只想要自定义 4 套的话按 `name` 过滤即可。
## 3. 深色模式的生效层级
`setDarkMode("dark")` 实测:`html` 类切到 `u-theme-dark`、`body` 背景变暗、`--u-type-primary` 重算为暗色降饱和变体。
已知小瑕疵:`.u-config-provider` 元素自身的 `u-theme-light` 类**不跟随**切换(uView Pro 内部响应性缺口,仅该类,不影响变量与 html/body 层生效)。
注意:本项目自定义样式大量写死色值(文件页白卡、`#2b8fff` 强调色等),**页面不会自动变暗**;要让整页跟随暗黑,需要把关键色逐个改走 `--u-type-*` 变量,属独立改造任务。
## 4. 渐变底:useThemeGradient(多页共用)
文件页头部各块(navbar/面包屑/搜索/快捷入口/分类条)已透明化,页面底用「主题色混白」的纵向渐变承接主题色。派生逻辑在 `src/composables/useThemeGradient.ts`,规则:
- `mixWithWhite(primary, ratio)`:主题 primary 按占比混白,`c*ratio + 255*(1-ratio)`,输出 `rgb()`;
- 三段停靠:顶部 25% 浓度 → 40% 高度 14% → 底部 5%(`linear-gradient(180deg, ...)`),保证任意主题都是同色系浅底,白卡与文字对比天然保持;
- 浓度可覆盖:`useThemeGradient({ top: 0.15, middle: 0.08, bottom: 0.03 })`。
新页面接入只要两行:
```text
const { themeBackground } = useThemeGradient();
<!-- 模板根节点 -->
<view class="xxx-page" :style="{ background: themeBackground }">
```
实测对照(顶部 = primary 混白 25%):green `#059669` → `rgb(193,229,218)`;purple `#7c3aed` → `rgb(222,206,251)`;dark `#0b3d91` → `rgb(194,207,228)`。
历史备注:文件页渐变曾经是写死的 `#b8e5fd → #eaf7fe`(蓝色系),不随主题;现已全部由本 composable 接管,**不要再在页面里加固定渐变覆盖**。
## 5. mine 页设置入口
`src/pages/tab/mine.vue` 两张卡片:
- **主题颜色**:遍历 `useTheme().themes` 渲染色点(背景取 `theme.color.primary`,选中打勾),点击 `setTheme(theme.name)`;
- **深色模式**:跟随系统/浅色/深色三段胶囊,点击 `setDarkMode(mode)`;`DarkMode` 类型顶层未导出,页面内以同构字面量联合 `"auto" | "light" | "dark"` 声明。
词条在语言包 `MinePage` 块(`src/locale/lang/zh-CN.json` / `en-US.json`);主题 `label`(霞光紫等)来自 `uview-pro.theme.ts`,是中文写死的——若要随语言切换需给主题加双 label 字段,暂未做。
## 6. 新增/修改主题的固定步骤
1. 在 `common/uview-pro.theme.ts` 数组增删一项(`name` 必须全局唯一,是 Storage 持久化的键值);
2. 如需换默认主题,改 `main.ts` 的 `defaultTheme`;
3. 无需动 mine 页——色点列表由 `themes` 响应式驱动;
4. 验证:`npx vue-tsc --noEmit -p tsconfig.json` 退出码 0 + 浏览器实测 mine 切换 → 文件页渐变联动 → F5 持久化。
+6 -4
View File
@@ -1,13 +1,15 @@
{
"pages": [
// 入口页在 H5 渲染在根路径;/login、/dashboard 等短地址由 router/h5-clean-url 运行时追加路由记录
// 入口页在 H5 渲染在根路径(当前入口=dashboard 首页,未登录由页面级守卫 useAuthGuard 送去登录页);
// /login、/dashboard 等短地址由 router/h5-clean-url 运行时追加路由记录
// 新增页面须同步登记 router/routes.ts 的 PAGE_PATHS,否则只有真实路径可访问
{
"path": "pages/login/login"
},
// 注意:换入口页须同步 routes.ts 里 "/" 的页面归属(resolvePageName)
{
"path": "pages/dashboard/dashboard"
},
{
"path": "pages/login/login"
},
// 错误页:H5 短地址由 CLEAN_PATH_OVERRIDES 指定为 /error/page40x,与旧 WebVue 地址一致
{
"path": "pages/error/page401"
+5 -12
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, onShow } from "@dcloudio/uni-app";
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { backendLocale, baseUrl, hasLoginSession } from "@/common/http.interceptor";
import { backendLocale, baseUrl } from "@/common/http.interceptor";
import { encryptLoginPassword } from "@/common/pwd-crypto";
import { LoginData as LoginDataClass } from "@/data/auto/loginData";
import { reloadLocales } from "@/locale";
@@ -36,17 +36,10 @@ onLoad((options) => {
});
/**
* 冷启动直接落在登录入口且本地已有登录会话 → 自动进 dashboard,
* 省掉"明明登录过还闪一下登录页"。令牌若已过期,dashboard 首个请求会触发请求层用
* RefreshToken 自动续期;续期失败再 goLogin 退回本页,不会卡在受保护页里。
* 被"有意送回登录页"的场景(401 去登录 / 403 重新登录)不必再设豁免:
* goLoginWithRedirect 已先清登录态,此刻 hasLoginSession 必为假,表单正常展示。
* 已登录访问 /login 不再自动放行进 dashboard——本页是"切换账号"的唯一入口,
* 自动跳走会让用户够不到登录表单。旧令牌在 Login 成功后被 updateLoginProfile 覆盖;
* 由守卫送回本页的场景 goLoginWithRedirect 已先 clearLoginState,会话本就是干净的。
*/
onShow(() => {
if (hasLoginSession()) {
uni.reLaunch({ url: redirectUrl.value });
}
});
// 登录页中英文切换:走 uView Pro 语言包(t / setLocale),语言包 name 为 zh-CN / en-US
const { t, setLocale, currentLocale } = useLocale();
+340 -24
View File
@@ -1,44 +1,360 @@
<script setup lang="ts">
/**
* 底部导航「文件」测试页:先占位,后续接相册/文件功能。
* 权限暂用 UserOperation(见 routes.ts 的 PAGE_PERMISSIONS);nav-guard 在 reLaunch 时做路由级校验,
* 这里再挂一次 useAuthGuard 兜冷启动直达(地址栏 / file、分享链接不经过导航 API)。
* 底部导航「文件」页:按截图拆解为独立组件(components/ 下各自一个 vue 文件),
* 页面只持有状态与数据,经 props 单向下发、事件(emit)上报,组件之间不互相引用:
* page-navbar 顶部导航(居中标题;仅子目录态显示返回箭头)
* file-breadcrumb 目录面包屑(仅子目录态:左起排列,放不下折成 根+…+尾部段)
* search-input 搜索框(v-model 即时过滤,确认键触发服务端搜索)
* quick-entry 快捷功能入口(横向滚动图标组)
* file-tab-filter 分类标签 + 排序 + 视图切换 + 多选开关
* file-grid-list 文件网格/列表卡片
* float-add-btn 右下角悬浮加号
* 数据来自后端 DMS 接口(composables/useDmsFiles,调用链对齐旧 WebVue DmsFileEnquiry.vue);
* 组件契约(data/custom/fileBrowser.ts)不关心数据源,演示数据到真实接口只动页面层。
* 目录导航:点文件夹下钻;子目录态隐藏底部 tabbar,返回箭头/面包屑退层级。
* 权限暂用 UserOperation(见 routes.ts 的 PAGE_PERMISSIONS);useAuthGuard 兜冷启动直达。
*/
import { useAuthGuard } from "@/composables";
import type { FileCategory, FileItem, FileTabItem, QuickEntryItem } from "@/data/custom/fileBrowser";
import { useLocale } from "uview-pro";
import { computed, ref, watch } from "vue";
import { useAuthGuard, useDmsFiles, useThemeGradient } from "@/composables";
import { pageUrl } from "@/router/routes";
useAuthGuard();
const { t } = useLocale();
// 页面底渐变跟随主题(派生逻辑在 composables/useThemeGradient,多页共用)
const { themeBackground } = useThemeGradient();
const { files, loading, errorText, pathStack, atRoot, init, retry, openFolder, back, goTo, search } = useDmsFiles();
// 首次显示拉根目录,之后每次回到页面只刷新当前目录(重进 tab 不该丢位置重新拉全链)
let started = false;
useAuthGuard(() => {
if (started) {
retry();
return;
}
started = true;
init();
});
// ===== 页面状态(全部单向下发,组件只上报意图) =====
const keyword = ref("");
const category = ref<FileCategory>("all");
const viewMode = ref<"grid" | "list">("list");
const selectMode = ref(false);
const selectedIds = ref<number[]>([]);
/** 排序模式:0 时间降序 / 1 时间升序 / 2 名称,点导航栏排序按钮循环 */
const sortMode = ref(0);
/* 搜索框 / 快捷入口显隐:navbar 右侧两个图标手动切换;
默认规则——进入子目录(有面包屑)时隐藏、回根目录恢复显示 */
const showSearch = ref(true);
const showQuick = ref(true);
watch(atRoot, (root) => {
showSearch.value = root;
showQuick.value = root;
});
/** 导航栏标题跟随当前目录:根为「我的云盘」,下钻后是文件夹名 */
const navTitle = computed(() => pathStack.value[pathStack.value.length - 1]?.displayName || t("FilePage.title"));
const quickEntries = computed<QuickEntryItem[]>(() => [
{ key: "recent", label: t("FilePage.recent"), icon: "clock", bgFrom: "#5ee7c1", bgTo: "#22c79f" },
{ key: "favorite", label: t("FilePage.favorite"), icon: "star-fill", bgFrom: "#ff8a8a", bgTo: "#f0413e" },
{ key: "downloaded", label: t("FilePage.downloaded"), icon: "arrow-down", bgFrom: "#a78bfa", bgTo: "#6d43e0" },
{ key: "team", label: t("FilePage.team"), icon: "account", bgFrom: "#63b3ff", bgTo: "#1f7ae0" },
{ key: "shared", label: t("FilePage.shared"), icon: "share-fill", bgFrom: "#5ec2ff", bgTo: "#2b8fff" },
]);
const tabs = computed<FileTabItem[]>(() => [
{ key: "all", label: t("FilePage.catAll") },
{ key: "image", label: t("FilePage.catImage") },
{ key: "audio", label: t("FilePage.catAudio") },
{ key: "video", label: t("FilePage.catVideo") },
{ key: "document", label: t("FilePage.catDocument") },
]);
/**
* 分类为本地过滤(后端按目录返回全量,切标签不该重新打接口);
* 选中分类时目录一起被过滤掉,只有「全部」显示文件夹;
* 关键字在确认键时走服务端 contain 过滤,输入过程中先本地收敛已加载数据。
*/
const visibleFiles = computed<FileItem[]>(() => {
const kw = keyword.value.trim().toLowerCase();
const list = files.value.filter(
f => (category.value === "all" || f.kind === category.value)
&& (!kw || f.name.toLowerCase().includes(kw)),
);
const bySort = (a: FileItem, b: FileItem) => {
if (sortMode.value === 2) {
return a.name.localeCompare(b.name, "zh-CN");
}
return sortMode.value === 1 ? a.sortTime - b.sortTime : b.sortTime - a.sortTime;
};
// 文件夹组恒置顶(对齐后端 IsDirectoryIndc desc 排序与截图样式),组内再按当前排序
const folders = list.filter(f => f.kind === "folder").sort(bySort);
const others = list.filter(f => f.kind !== "folder").sort(bySort);
return [...folders, ...others];
});
function toast(title: string): void {
uni.showToast({ title, icon: "none" });
}
/** 待接入真实功能时的统一占位提示 */
function todoTip(): void {
toast(t("FilePage.todoTip"));
}
// ===== 导航栏 / 面包屑 =====
function onToggleSearch(): void {
showSearch.value = !showSearch.value;
}
function onToggleQuick(): void {
showQuick.value = !showQuick.value;
}
async function onBack(): Promise<void> {
// 返回箭头只在子目录态出现:退一层目录;根目录走页面退出兜底
if (await back()) {
keyword.value = "";
selectedIds.value = [];
return;
}
// tab 页 reLaunch 进来的没有返回栈,回不去就回首页
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.reLaunch({ url: pageUrl("dashboard") });
}
/** 点面包屑某一段:截断到该层并重查(goTo 内部处理「已在该层」空点) */
async function onCrumbNav(index: number): Promise<void> {
if (index >= pathStack.value.length - 1) {
return;
}
await goTo(index);
keyword.value = "";
selectedIds.value = [];
}
const sortLabels = computed(() => [
t("FilePage.sortTimeDesc"),
t("FilePage.sortTimeAsc"),
t("FilePage.sortName"),
]);
function onSort(): void {
sortMode.value = (sortMode.value + 1) % 3;
toast(sortLabels.value[sortMode.value] ?? "");
}
// ===== 搜索 / 快捷入口 =====
function onSearch(value: string): void {
search(value);
}
function onQuickClick(key: string): void {
const entry = quickEntries.value.find(e => e.key === key);
toast(entry ? `${entry.label} · ${t("FilePage.todoTip")}` : t("FilePage.todoTip"));
}
// ===== 分类 / 视图 / 多选 =====
function onCategoryChange(key: FileCategory): void {
category.value = key;
selectedIds.value = [];
}
function onToggleView(): void {
viewMode.value = viewMode.value === "grid" ? "list" : "grid";
}
function onToggleSelect(): void {
selectMode.value = !selectMode.value;
if (!selectMode.value) {
selectedIds.value = [];
}
}
function onToggleSelectItem(id: number): void {
const index = selectedIds.value.indexOf(id);
if (index >= 0) {
selectedIds.value.splice(index, 1);
return;
}
selectedIds.value.push(id);
}
// ===== 列表条目 / 悬浮按钮 =====
async function onOpen(file: FileItem): Promise<void> {
// 文件夹下钻走真实接口;打开文件(预览/播放)尚未实现,先占位
if (await openFolder(file)) {
keyword.value = "";
selectedIds.value = [];
return;
}
toast(`${file.name} · ${t("FilePage.todoTip")}`);
}
function onItemMore(file: FileItem): void {
toast(`${file.name} · ${t("FilePage.todoTip")}`);
}
function onAdd(): void {
todoTip();
}
</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>
<app-page :hide-nav="true" :show-tabbar="atRoot">
<view class="file-page" :style="{ background: themeBackground }">
<!-- 上半屏整体固定,只有文件列表所在的 scroll-view 内部滚动(整页不滚) -->
<view class="file-page__head">
<page-navbar :title="navTitle" :show-back="!atRoot" :search-on="showSearch" :quick-on="showQuick" @back="onBack"
@toggle-search="onToggleSearch" @toggle-quick="onToggleQuick" />
<!-- 子目录态才有的路径条:白色衬底包住面包屑(外面不再是灰页面底),左起全量显示,溢出折叠 -->
<view v-if="!atRoot" class="file-page__crumb">
<file-breadcrumb :items="pathStack" @nav="onCrumbNav" />
</view>
<!-- 搜索框与快捷入口由 navbar 右侧图标切换显隐(进子目录默认隐藏);
快捷入口外层包装负责卡片定位(scroll-view 自身不能带左右 margin) -->
<search-input v-if="showSearch" v-model="keyword" :placeholder="t('FilePage.searchPlaceholder')"
@search="onSearch" />
<view v-if="showQuick" class="file-page__quick">
<quick-entry :items="quickEntries" @click="onQuickClick" />
</view>
<file-tab-filter :tabs="tabs" :active="category" :view-mode="viewMode" :select-mode="selectMode"
@change="onCategoryChange" @sort="onSort" @toggle-view="onToggleView" @toggle-select="onToggleSelect" />
<view v-if="selectMode" class="file-page__selection">
<text class="file-page__selection-text">
{{ t("FilePage.selectedCount", { count: selectedIds.length }) }}
</text>
</view>
</view>
<!-- 滚动窗口外层包装:margin/圆角/白底/裁剪放这层(scroll-view 根节点固定
100% 宽,自带左右 margin 会右侧溢出被裁,与 quick-entry 同坑实测过) -->
<view class="file-page__scroll-wrap">
<scroll-view scroll-y class="file-page__scroll">
<!-- 文件列表内容容器:卡片外观由 wrap 层负责 -->
<view class="file-page__card">
<view v-if="loading" class="file-page__state">
<text class="file-page__state-text">
{{ t("FilePage.loading") }}
</text>
</view>
<view v-else-if="errorText" class="file-page__state">
<text class="file-page__state-text file-page__state-text--error">
{{ errorText }}
</text>
<text class="file-page__retry" @click="retry()">
{{ t("FilePage.retryBut") }}
</text>
</view>
<file-grid-list v-else :files="visibleFiles" :view-mode="viewMode" :select-mode="selectMode"
:selected-ids="selectedIds" :empty-text="t('FilePage.emptyText')" @open="onOpen" @more="onItemMore"
@toggle-select="onToggleSelectItem" />
</view>
<!-- 列表尾部让位悬浮 tabbar;子目录态 tabbar 隐藏,占位相应收小 -->
<view class="file-page__tabbar-space" :class="{ 'file-page__tabbar-space--slim': !atRoot }" />
</scroll-view>
</view>
<float-add-btn @click="onAdd" />
</view>
</app-page>
</template>
<style lang="scss" scoped>
.tab-page {
.file-page {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
overflow: hidden;
/* 渐变底由 script 的 pageBackground 按主题 primary 动态计算(内联 style 下发) */
}
.file-page__head {
flex-shrink: 0;
}
/* 面包屑分区:透明衬底露灰页面底,与其余卡片同节奏的间隔(面包屑自身就是白卡片) */
.file-page__crumb {
padding: 20rpx 24rpx 0;
}
/* 快捷入口卡片定位(组件内 scroll-view 不能自带左右 margin,挪到这层) */
.file-page__quick {
margin: 20rpx 24rpx 0;
}
/* 滚动窗口外层包装=卡片本体:margin/圆角/白底/裁剪在这层,
左右 20rpx 间隔对称(scroll-view 自身带 margin 会右侧溢出被裁) */
.file-page__scroll-wrap {
flex: 1;
min-height: 0;
margin: 20rpx 20rpx 0;
border-radius: 32rpx 32rpx 0 0;
background-color: #ffffff;
overflow: hidden;
}
/* scroll-view 只当纯滚动容器垫满 wrap,滚动内容被 wrap 的圆角窗口裁剪 */
.file-page__scroll {
height: 100%;
}
.file-grid-list--list[data-v-2dfb0235] {
padding: 0.15rem 0.25rem 0.15rem !important;
}
.file-page__tabbar-space {
height: 140rpx;
&--slim {
height: 40rpx;
}
}
.file-page__selection {
margin: 20rpx 24rpx 0;
padding: 16rpx 32rpx;
border-radius: 24rpx;
background-color: #ffffff;
}
.file-page__selection-text {
font-size: 24rpx;
color: #2b8fff;
}
.file-page__state {
display: flex;
align-items: center;
padding: 120rpx 40rpx;
justify-content: center;
gap: 24rpx;
padding: 64rpx 32rpx;
}
.tab-page__title {
font-size: 40rpx;
font-weight: 600;
color: $u-main-color;
}
.tab-page__hint {
margin-top: 16rpx;
.file-page__state-text {
font-size: 26rpx;
color: $u-tips-color;
color: #9aa0a6;
}
.file-page__state-text--error {
color: #f0413e;
}
.file-page__retry {
padding: 8rpx 28rpx;
border-radius: 999rpx;
font-size: 26rpx;
color: #2b8fff;
background-color: #eaf4ff;
}
</style>
+147 -18
View File
@@ -1,43 +1,172 @@
<script setup lang="ts">
/**
* 底部导航「我的」测试页:先占位,后续接个人资料/设置/关于入口。
* 底部导航「我的」页:主题外观设置(uView Pro 主题系统)+ 后续个人资料入口占位。
* 主题列表与默认值在 main.ts 注册(common/uview-pro.theme.ts 的四套配色);
* useTheme 的 setTheme/setDarkMode 自带 Storage 持久化(uview-pro-theme 键),
* 冷启动时由插件 initTheme 恢复,这里不需要再接 store。
* 权限暂用 UserOperation(见 routes.ts 的 PAGE_PERMISSIONS);useAuthGuard 兜冷启动直达。
*/
import { useLocale, useTheme } from "uview-pro";
import { useAuthGuard } from "@/composables";
/** 与 uview-pro 内部 DarkMode 联合对齐(顶层未导出该类型,本地声明同构字面量) */
type DarkModeValue = "auto" | "light" | "dark";
const { t } = useLocale();
useAuthGuard();
// currentTheme/darkMode 是包级单例 ref:任意页切换,本页选中态实时跟随
const { themes, currentTheme, setTheme, darkMode, setDarkMode } = useTheme();
const darkOptions: { value: DarkModeValue; label: string }[] = [
{ value: "auto", label: t("MinePage.darkAuto") },
{ value: "light", label: t("MinePage.darkLight") },
{ value: "dark", label: t("MinePage.darkDark") },
];
function onPickTheme(name: string): void {
setTheme(name);
}
function onPickDarkMode(mode: DarkModeValue): void {
setDarkMode(mode);
}
</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>
<app-page :nav-title="t('MinePage.title')" :show-nav-back="false" :show-tabbar="true">
<view class="mine-page">
<!-- 主题配色:色点取自各主题 primary,选中打勾 -->
<view class="mine-card">
<text class="mine-card__title">
{{ t("MinePage.themeTitle") }}
</text>
<view class="mine-themes">
<view v-for="theme in themes" :key="theme.name" class="mine-theme" @tap="onPickTheme(theme.name)">
<view class="mine-theme__dot"
:class="{ 'mine-theme__dot--active': theme.name === currentTheme?.name }"
:style="{ backgroundColor: theme.color.primary }">
<u-icon v-if="theme.name === currentTheme?.name" name="checkmark" size="30"
color="#ffffff" />
</view>
<text class="mine-theme__label">
{{ theme.label }}
</text>
</view>
</view>
<text class="mine-card__hint">
{{ t("MinePage.themeHint") }}
</text>
</view>
<!-- 深色模式:跟随系统 / 强制浅色 / 强制暗黑 三段 -->
<view class="mine-card">
<text class="mine-card__title">
{{ t("MinePage.darkTitle") }}
</text>
<view class="mine-dark">
<view v-for="opt in darkOptions" :key="opt.value" class="mine-dark__seg"
:class="{ 'mine-dark__seg--active': opt.value === darkMode }" @tap="onPickDarkMode(opt.value)">
<text class="mine-dark__text">
{{ opt.label }}
</text>
</view>
</view>
</view>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.tab-page {
.mine-page {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 24rpx;
}
/* 白底圆角卡片:与文件页分区卡片同一风格语言 */
.mine-card {
padding: 32rpx;
border-radius: 24rpx;
background-color: #ffffff;
}
.mine-card__title {
font-size: 30rpx;
font-weight: 600;
color: #1f2329;
}
.mine-card__hint {
margin-top: 20rpx;
font-size: 24rpx;
color: #9aa0a6;
}
.mine-themes {
display: flex;
align-items: center;
gap: 40rpx;
margin-top: 28rpx;
}
.mine-theme {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 40rpx;
gap: 12rpx;
}
.tab-page__title {
font-size: 40rpx;
font-weight: 600;
color: $u-main-color;
.mine-theme__dot {
display: flex;
align-items: center;
justify-content: center;
width: 88rpx;
height: 88rpx;
border-radius: 50%;
box-shadow: 0 8rpx 16rpx rgba(31, 35, 41, 0.12);
&--active {
border: 6rpx solid #ffffff;
outline: 4rpx solid #1f2329;
}
}
.tab-page__hint {
margin-top: 16rpx;
.mine-theme__label {
font-size: 24rpx;
color: #5c626b;
}
/* 三段胶囊:选中段深底白字(跟随 currentTheme primary 的中性替代) */
.mine-dark {
display: flex;
gap: 8rpx;
margin-top: 28rpx;
padding: 8rpx;
border-radius: 999rpx;
background-color: #f2f3f5;
}
.mine-dark__seg {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
height: 64rpx;
border-radius: 999rpx;
&--active {
background-color: #1f2329;
}
}
.mine-dark__text {
font-size: 26rpx;
color: $u-tips-color;
color: #5c626b;
.mine-dark__seg--active & {
color: #ffffff;
}
}
</style>
+18 -4
View File
@@ -34,6 +34,7 @@ function isLoggedIn(): boolean {
*
* 顺序即语义:地址不存在(404) -> 没登录(401) -> 没权限(403)
* 403 必须排在 401 之后:未登录时权限档案多半也是空的,先给 401 才谈得上"去登录"
* 注意 error401 只是"没令牌"的判定结果,落点由 dispatchBlocked 决定(直接去登录页,不经过 401 页)
*/
function evaluate(url: string): ErrorPageName | null {
const name: PageName | null = resolvePageName(url);
@@ -52,6 +53,20 @@ function evaluate(url: string): ErrorPageName | null {
return null;
}
/**
* 被拦地址的统一分派:
* - 没令牌(401) → 直接去登录表单(goLoginWithRedirect 带 redirect,登录成功回原页),
* 不再多一跳 401 错误页——首页场景下"未登录=去登录",401 页保留给手动访问 /error/page401 等场景;
* - 其余(403 等)→ 对应错误页
*/
function dispatchBlocked(blocked: ErrorPageName, from: string): void {
if (blocked === "error401") {
goLoginWithRedirect(from);
return;
}
jumpToError(blocked, from);
}
/** 跳错误页;redirectTo 保住返回栈("返回上一页"还有内容),页面栈为空时退化成 reLaunch */
function jumpToError(blocked: ErrorPageName, from: string): void {
const url = errorPageUrl(blocked, from);
@@ -81,7 +96,7 @@ export function ensureCurrentPageAuthorized(): boolean {
}
// getCurrentPages 只会给出真实路径,而 ?from= 是给用户看的:换成当前端的地址形态
const name = resolvePageName(path);
jumpToError(blocked, name ? pageUrl(name) : path);
dispatchBlocked(blocked, name ? pageUrl(name) : path);
return false;
}
@@ -95,7 +110,7 @@ export function setupNavGuard(): void {
if (!blocked) {
return true;
}
jumpToError(blocked, url);
dispatchBlocked(blocked, url);
// 取消本次导航
return false;
},
@@ -128,8 +143,7 @@ export function setupUnknownPathFallback(): void {
* redirect 与 from 一样不做预编码,理由见 routes.ts 的 errorPageUrl
*
* 进登录页前先清登录态:走到这里都是"有意去登录/换号"(401 去登录、403 重新登录),
* 旧会话整体作废——否则登录页的冷启动自动放行(hasLoginSession)会把这个还带着令牌的
* 用户直接弹回原页,403"重新登录"根本够不到登录表单。
* 旧会话整体作废,登录页始终是干净的换号入口(登录页已不再自动放行跳转,清态是进一步兜底)。
*/
export function goLoginWithRedirect(from?: string): void {
clearLoginState();
+10 -10
View File
@@ -71,11 +71,11 @@ export const PAGE_PERMISSIONS: Partial<Record<PageName, PermissionNames>> = {
// 底部导航 5 个 tab:真实权限尚未定义,按当前要求统一用 UserOperation 占位。
// 注意 dashboard 也被登记后,登录落地页(首页)会开始按 UserOperation 做路由级校验;
// 路由级是 fail-open(档案缺失/为空时放行),真正的边界仍在后端。
dashboard: "UserOperation",
fileTab: "UserOperation",
mediaTab: "UserOperation",
shareTab: "UserOperation",
mineTab: "UserOperation",
dashboard: "DmsFile",
fileTab: "DmsFile",
mediaTab: "DmsFile",
shareTab: "DmsFile",
mineTab: "DmsFile",
};
/**
@@ -147,12 +147,13 @@ export function resolvePageName(url: string): PageName | null {
}
const path = normalizePath(url);
const names = Object.keys(PAGE_PATHS) as PageName[];
// "/" 是 H5 入口页(登录页)的路由 path,不对应任何字面配置
// "/" 是 H5 入口页的路由 path,不对应任何字面配置——指向 pages.json 的第一个页面(当前为 dashboard)。
// 换入口页必须两处同步改:pages.json 的 pages 顺序 + 这里,否则未登录判断/redirect 校验会认错页面
if (path === "/") {
return "login";
return "dashboard";
}
const hit = names.find(
name => path === toRealPath(name) || path === toCleanPath(name),
(name) => path === toRealPath(name) || path === toCleanPath(name),
);
return hit ?? null;
}
@@ -190,8 +191,7 @@ export function decodeQueryPath(value: unknown): string {
}
try {
return decodeURIComponent(raw);
}
catch {
} catch {
return raw;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 62 KiB