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

+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>