ow
This commit is contained in:
1 parent
3194f98ac0
commit
e740ec77ec
24 files changed
+1561
-65
No files matched your search
Whitespace-only changes.
@@ -73,6 +73,21 @@ function getToken(): string {
|
||||
return getJwtInfo()?.AccessToken ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化后的 Authorization 头值:ASP.NET Core JWT 认证要求 "Bearer " 前缀,
|
||||
* 存量令牌可能带也可能不带前缀,统一在这里补齐——
|
||||
* 主拦截器与 uploadFile/downloadFile/上传队列等旁路通道共用这一个口径,
|
||||
* 否则旁路裸发 token 会被后端 401(上传失败实测根因)。
|
||||
* @returns 无令牌时返回空串(调用方据此不挂 Authorization 头)
|
||||
*/
|
||||
export function bearerAuth(): string {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return "";
|
||||
}
|
||||
return token.startsWith("Bearer ") ? token : `Bearer ${token}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证失败统一收尾:必要时清登录态并跳登录页;
|
||||
* 已经在登录页就不再跳,避免死循环
|
||||
@@ -224,6 +239,18 @@ function detectBusinessException(data: any): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默提取业务异常消息(不弹提示):供批量传输通道使用——
|
||||
* 上传队列的失败要落在任务行上逐条展示,不能每条弹一个模态。
|
||||
* @returns 命中返回异常消息,未命中返回空串
|
||||
*/
|
||||
export function extractBusinessError(data: any): string {
|
||||
if (!data || typeof data !== "object" || !data.SourceCode) {
|
||||
return "";
|
||||
}
|
||||
return extractErrorMessage(data) || "业务处理失败";
|
||||
}
|
||||
|
||||
// ==================== 全局配置(httpPlugin 注册用) ====================
|
||||
|
||||
const httpRequestConfig: RequestConfig = {
|
||||
@@ -252,12 +279,9 @@ const httpInterceptor: RequestInterceptor = {
|
||||
config.method = String(
|
||||
config.method || "GET",
|
||||
).toUpperCase() as RequestOptions["method"];
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
// ASP.NET Core JWT Bearer 认证要求 Authorization 带 "Bearer " 前缀(旧系统同)
|
||||
config.header.Authorization = token.startsWith("Bearer ")
|
||||
? token
|
||||
: `Bearer ${token}`;
|
||||
const auth = bearerAuth();
|
||||
if (auth) {
|
||||
config.header.Authorization = auth;
|
||||
}
|
||||
config.header.locale = backendLocale();
|
||||
return config;
|
||||
@@ -360,7 +384,7 @@ export interface UploadOptions {
|
||||
export function uploadFile(options: UploadOptions): Promise<any> {
|
||||
const { url, filePath, name = "file", formData, header = {} } = options;
|
||||
const fullUrl = url.indexOf("http") === 0 ? url : baseUrl + url;
|
||||
const token = getToken();
|
||||
const auth = bearerAuth();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
@@ -369,7 +393,7 @@ export function uploadFile(options: UploadOptions): Promise<any> {
|
||||
name,
|
||||
formData,
|
||||
timeout,
|
||||
header: token ? { Authorization: token, ...header } : header,
|
||||
header: auth ? { Authorization: auth, ...header } : header,
|
||||
success: (res) => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
const message = `上传失败[${res.statusCode}]`;
|
||||
@@ -448,7 +472,7 @@ function timestamp(): string {
|
||||
export async function downloadFile(options: DownloadOptions): Promise<void> {
|
||||
const { url, method = "POST", data, filename, extension = "xlsx" } = options;
|
||||
const fullUrl = url.indexOf("http") === 0 ? url : baseUrl + url;
|
||||
const token = getToken();
|
||||
const auth = bearerAuth();
|
||||
|
||||
const res = await new Promise<UniApp.RequestSuccessCallbackResult>(
|
||||
(resolve, reject) => {
|
||||
@@ -458,7 +482,7 @@ export async function downloadFile(options: DownloadOptions): Promise<void> {
|
||||
data,
|
||||
timeout,
|
||||
responseType: "arraybuffer",
|
||||
header: token ? { Authorization: token } : {},
|
||||
header: auth ? { Authorization: auth } : {},
|
||||
success: resolve,
|
||||
fail: err => reject(new Error(err.errMsg || "下载失败")),
|
||||
});
|
||||
|
||||
@@ -20,6 +20,11 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** 自定义返回动作(u-navbar customBack 透传):页面做无栈兜底跳转时用 */
|
||||
customBack: {
|
||||
type: Function as PropType<() => void>,
|
||||
default: undefined,
|
||||
},
|
||||
customStyle: {
|
||||
type: [String, Object] as PropType<string | Record<string, any>>,
|
||||
default: "",
|
||||
@@ -40,11 +45,9 @@ const background = reactive({
|
||||
<template>
|
||||
<view class="app-page" :class="{ 'has-tabbar': showTabbar }" :style="$u.toStyle(customStyle)">
|
||||
<!-- #ifndef MP-ALIPAY -->
|
||||
<u-navbar
|
||||
v-if="!hideNav" :is-back="showNavBack && !showTabbar" :title="navTitle" :background="background" :is-fixed="true"
|
||||
:immersive="false" back-icon-name="arrow-leftward" title-width="350" title-color="#ffffff"
|
||||
back-icon-color="#ffffff"
|
||||
/>
|
||||
<u-navbar v-if="!hideNav" :is-back="showNavBack && !showTabbar" :title="navTitle" :background="background"
|
||||
:is-fixed="true" :immersive="false" :custom-back="customBack" back-icon-name="arrow-leftward" title-width="350"
|
||||
title-color="#ffffff" back-icon-color="#ffffff" />
|
||||
<!-- #endif -->
|
||||
<u-transition name="slide-left" :appear="true">
|
||||
<slot />
|
||||
@@ -55,21 +58,21 @@ const background = reactive({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-page {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
// padding-bottom: 30rpx;
|
||||
overflow-y: auto;
|
||||
background-color: $u-bg-white;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: $u-main-color;
|
||||
transition: background 0.3s ease;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
// padding-bottom: 30rpx;
|
||||
overflow-y: auto;
|
||||
background-color: $u-bg-white;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: $u-main-color;
|
||||
transition: background 0.3s ease;
|
||||
|
||||
&.has-tabbar {
|
||||
background-image: linear-gradient(135deg,
|
||||
rgba(var(--u-type-primary-rgb, 41, 121, 255), 0.04) 0%,
|
||||
rgba(var(--u-type-success-rgb, 25, 190, 107), 0.04) 40%,
|
||||
rgba(var(--u-type-warning-rgb, 255, 153, 0), 0.04) 100%);
|
||||
}
|
||||
&.has-tabbar {
|
||||
background-image: linear-gradient(135deg,
|
||||
rgba(var(--u-type-primary-rgb, 41, 121, 255), 0.04) 0%,
|
||||
rgba(var(--u-type-success-rgb, 25, 190, 107), 0.04) 40%,
|
||||
rgba(var(--u-type-warning-rgb, 255, 153, 0), 0.04) 100%);
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 新建操作面板:点悬浮「+」弹出的底部抽屉,只两项——上传文件 / 新建文件夹
|
||||
* (对应旧系统 OperatorStation 的 UploadOutlined + FolderAddOutlined 能力)。
|
||||
* 组件只渲染与派发 action,真实上传/建目录由页面接 DMS 接口。
|
||||
*/
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed } from "vue";
|
||||
|
||||
interface AddAction {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
defineProps({
|
||||
/** 面板是否展开 */
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 抽屉关闭(遮罩/下滑) */
|
||||
(e: "close"): void;
|
||||
/** 点了某项新建动作 */
|
||||
(e: "action", key: string): void;
|
||||
}>();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
// computed:切换语言后文案跟随重算(tab 页组件实例常驻,setup 只跑一次)
|
||||
const actions = computed<AddAction[]>(() => [
|
||||
{ key: "upload", label: t("FilePage.addUpload"), icon: "arrow-up-fill" },
|
||||
{ key: "folder", label: t("FilePage.addFolder"), icon: "plus-circle-fill" },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- u-popup 只认 modelValue(v-model),绑 :show 会静默弹不出 -->
|
||||
<u-popup :model-value="visible" mode="bottom" round="24" @close="emit('close')">
|
||||
<view class="file-add-sheet">
|
||||
<text class="file-add-sheet__title">
|
||||
{{ t("FilePage.addTitle") }}
|
||||
</text>
|
||||
<view v-for="action in actions" :key="action.key" class="file-add-sheet__row" @tap="emit('action', action.key)">
|
||||
<u-icon :name="action.icon" size="40" color="#2b8fff" />
|
||||
<text class="file-add-sheet__row-text">
|
||||
{{ action.label }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="file-add-sheet__safe" />
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.file-add-sheet {
|
||||
padding: 40rpx 32rpx 0;
|
||||
background-color: #ffffff;
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
padding: 0 8rpx 24rpx;
|
||||
font-size: 26rpx;
|
||||
color: #8f959e;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 30rpx 32rpx;
|
||||
border-radius: 20rpx;
|
||||
background-color: #f5f6f8;
|
||||
}
|
||||
|
||||
&__row-text {
|
||||
font-size: 30rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
/* 底部留白:贴屏安全区(H5 无 env 时也有 32rpx 呼吸) */
|
||||
&__safe {
|
||||
height: calc(32rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,11 @@ const props = defineProps({
|
||||
type: Array as PropType<PathNode[]>,
|
||||
default: () => [],
|
||||
},
|
||||
/** 只有根一段时也渲染(传输页目标目录用):文件页惯例是根目录不显示面包屑,默认关 */
|
||||
showSingle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -112,7 +117,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view v-if="items.length > 1" class="file-breadcrumb">
|
||||
<view v-if="items.length > 1 || (showSingle && items.length === 1)" class="file-breadcrumb">
|
||||
<text class="file-breadcrumb__root" @tap="onSegmentTap({ node: items[0], index: 0, ellipsis: false })">
|
||||
{{ items[0].displayName }}
|
||||
</text>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 分类标签筛选:横向标签(全部/图片/视频/文档/音频)+ 右侧「列表视图切换」「多选」按钮。
|
||||
* 分类标签筛选:横向标签(全部/图片/视频/文档/音频)+ 右侧「新建文件夹」「列表视图切换」「多选」按钮。
|
||||
* 标签内容由页面传入(翻译在页面做);当前选中、视图模式、多选态都是外部状态,
|
||||
* 组件只上报用户意图(change / toggle-view / toggle-select),自身不改任何东西。
|
||||
*/
|
||||
import type { PropType } from "vue";
|
||||
import type { FileTabItem } from "@/data/custom/fileBrowser";
|
||||
import { folderGlyph } from "@/utils/folderGlyph";
|
||||
import { selectGlyph } from "@/utils/selectGlyph";
|
||||
import { sortGlyph } from "@/utils/sortGlyph";
|
||||
|
||||
defineProps({
|
||||
/** 分类标签列表 */
|
||||
@@ -35,7 +38,15 @@ const emit = defineEmits<{
|
||||
(e: "sort"): void;
|
||||
(e: "toggle-view"): void;
|
||||
(e: "toggle-select"): void;
|
||||
(e: "new-folder"): void;
|
||||
}>();
|
||||
|
||||
/* 多选开关的自绘字形(用户在 iconfont 挑选的方框+箭头):颜色固定,模块级生成一次 */
|
||||
const selectIconDark = selectGlyph("#1f2329");
|
||||
/* 新建文件夹的自绘字形(用户挑选的文件夹+加号):与「+」直传拆分后的专属入口 */
|
||||
const folderIconDark = folderGlyph("#1f2329");
|
||||
/* 排序的自绘字形(用户挑选的三横线+下箭头):替换原 level 字体图标 */
|
||||
const sortIconDark = sortGlyph("#1f2329");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -49,18 +60,22 @@ const emit = defineEmits<{
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="file-tab-filter__tools">
|
||||
<!-- 排序(从页面导航栏迁来,倒数第3位):level 阶梯横线,比原 order 更有排序感 -->
|
||||
<!-- 新建文件夹:直接弹命名对话框(上传已从悬浮「+」拆分,这里只管建目录) -->
|
||||
<view class="file-tab-filter__tool" @tap="emit('new-folder')">
|
||||
<image class="file-tab-filter__tool-img--folder" :src="folderIconDark" mode="aspectFit" />
|
||||
</view>
|
||||
<!-- 排序(从页面导航栏迁来):自绘三横线+下箭头,比原 level 更有降序感 -->
|
||||
<view class="file-tab-filter__tool" @tap="emit('sort')">
|
||||
<u-icon name="level" size="36" color="#1f2329" />
|
||||
<image class="file-tab-filter__tool-img--sort" :src="sortIconDark" mode="aspectFit" />
|
||||
</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="selectMode ? 'close-circle' : 'checkmark-circle'" size="36"
|
||||
:color="selectMode ? '#2b8fff' : '#1f2329'" />
|
||||
<image v-if="!selectMode" class="file-tab-filter__tool-img" :src="selectIconDark" mode="aspectFit" />
|
||||
<u-icon v-else name="close-circle" size="36" color="#2b8fff" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -87,8 +102,8 @@ const emit = defineEmits<{
|
||||
.file-tab-filter__tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding-left: 20rpx;
|
||||
gap: 8rpx;
|
||||
padding-left: 4rpx;
|
||||
}
|
||||
|
||||
.file-tab-filter__tab {
|
||||
@@ -120,4 +135,22 @@ const emit = defineEmits<{
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
}
|
||||
|
||||
/* 自绘多选字形:与同行 u-icon size=36 对齐 */
|
||||
.file-tab-filter__tool-img {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
}
|
||||
|
||||
/* 排序字形:原图 viewBox 1178×1024 非正方形,按 1.15:1 给宽高保持视觉重量 */
|
||||
.file-tab-filter__tool-img--sort {
|
||||
width: 38rpx;
|
||||
height: 33rpx;
|
||||
}
|
||||
|
||||
/* 新建文件夹字形:原图内容只占 viewBox 约 78%(四周留白多),放大补偿到与旁边同视觉大小 */
|
||||
.file-tab-filter__tool-img--folder {
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,9 @@
|
||||
* 悬浮操作按钮:蓝色渐变「+」,可在屏幕内自由拖动,松手后位置写入 Storage 下次启动恢复。
|
||||
* 只做外观与点击上报;新建/上传等动作语义由页面处理。
|
||||
* 拖动实现:touch 事件(真机/App)+ mouse 事件(H5 桌面调试)双轨;
|
||||
* 位移小于阈值视为轻点(emit click),超过阈值才算拖动(touch 后屏蔽 mouse 兼容事件防双触发)。
|
||||
* 位移小于阈值视为轻点、超过才算拖动。点击上报走原生 click(onTap)而非 touchend/mouseup
|
||||
* 手动 emit:保留浏览器用户激活链,否则轻点里同步拉起文件选择器等 gesture API 会被静默拦截;
|
||||
* 拖动收尾置 suppressClick 吞掉随后的原生 click,防拖完误触。
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
@@ -90,6 +92,9 @@ function onDragMove(x: number, y: number): void {
|
||||
pos.value = clampPos(originPos.x + dx, originPos.y + dy);
|
||||
}
|
||||
|
||||
/** 拖动收尾后浏览器还会补发一次原生 click,置位吞掉它(轻点不置位,由 onTap 正常上报) */
|
||||
let suppressClick = false;
|
||||
|
||||
function onDragEnd(): void {
|
||||
if (!dragging) {
|
||||
return;
|
||||
@@ -97,10 +102,17 @@ function onDragEnd(): void {
|
||||
dragging = false;
|
||||
if (moved) {
|
||||
uni.setStorageSync(STORAGE_KEY, pos.value);
|
||||
suppressClick = true;
|
||||
}
|
||||
else {
|
||||
emit("click");
|
||||
}
|
||||
|
||||
/** 唯一点击出口:原生 click(鼠标轻点/触屏补发)保留用户手势,页面才能在里面拉起文件选择器 */
|
||||
function onTap(): void {
|
||||
if (suppressClick) {
|
||||
suppressClick = false;
|
||||
return;
|
||||
}
|
||||
emit("click");
|
||||
}
|
||||
|
||||
// ===== touch(真机 / App)=====
|
||||
@@ -128,7 +140,7 @@ function onMouseDown(e: MouseEvent): void {
|
||||
if (touchHandled) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
// 注意不能 preventDefault:它会取消后续原生 click 及其用户激活,onTap 就再也收不到点击
|
||||
onDragStart(e.clientX, e.clientY);
|
||||
const move = (ev: MouseEvent) => onDragMove(ev.clientX, ev.clientY);
|
||||
const up = () => {
|
||||
@@ -143,7 +155,7 @@ function onMouseDown(e: MouseEvent): void {
|
||||
|
||||
<template>
|
||||
<view class="float-add-btn" :style="posStyle" @touchstart="onTouchStart" @touchmove.stop.prevent="onTouchMove"
|
||||
@touchend="onTouchEnd" @mousedown="onMouseDown">
|
||||
@touchend="onTouchEnd" @mousedown="onMouseDown" @click="onTap">
|
||||
<u-icon name="plus" size="52" color="#ffffff" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 新建文件夹命名对话框:居中卡片(标题 + 输入 + 取消/创建),
|
||||
* 只负责收集名称——非法字符校验、CreateDmsFolder 接口与结果提示由页面分流。
|
||||
* 提交成功后由父级收起 visible;submitting 期间禁用按钮防连点。
|
||||
*/
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
/** 对话框是否展开 */
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** 创建请求在途:置灰两个按钮 */
|
||||
submitting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 取消/遮罩关闭 */
|
||||
(e: "close"): void;
|
||||
/** 点了创建且名称非空 */
|
||||
(e: "confirm", name: string): void;
|
||||
}>();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const name = ref("");
|
||||
|
||||
/** 空白名不算可提交 */
|
||||
const canSubmit = computed(() => name.value.trim().length > 0);
|
||||
|
||||
// 每次展开清掉上次的残留输入(对话框实例常驻 tab 页)
|
||||
watch(
|
||||
() => props.visible,
|
||||
(on) => {
|
||||
if (on) {
|
||||
name.value = "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function onConfirm(): void {
|
||||
if (!props.submitting && canSubmit.value) {
|
||||
emit("confirm", name.value.trim());
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- u-popup 只认 modelValue(v-model);提交中禁点遮罩防误关 -->
|
||||
<u-popup :model-value="props.visible" mode="center" round="24" :close-on-click-overlay="!props.submitting"
|
||||
@close="emit('close')">
|
||||
<view class="folder-name-dialog">
|
||||
<text class="folder-name-dialog__title">
|
||||
{{ t("FilePage.addFolder") }}
|
||||
</text>
|
||||
<input v-model="name" class="folder-name-dialog__input" :placeholder="t('FilePage.folderPlaceholder')"
|
||||
:maxlength="50" :disabled="props.submitting" confirm-type="done" @confirm="onConfirm">
|
||||
<view class="folder-name-dialog__actions">
|
||||
<view class="folder-name-dialog__btn" @click="emit('close')">
|
||||
<text class="folder-name-dialog__btn-text">
|
||||
{{ t("FilePage.folderCancel") }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="folder-name-dialog__btn folder-name-dialog__btn--primary"
|
||||
:class="{ 'folder-name-dialog__btn--muted': !canSubmit || props.submitting }" @click="onConfirm">
|
||||
<text class="folder-name-dialog__btn-text folder-name-dialog__btn-text--primary">
|
||||
{{ t("FilePage.folderConfirm") }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.folder-name-dialog {
|
||||
width: 560rpx;
|
||||
padding: 40rpx 32rpx 32rpx;
|
||||
background-color: #ffffff;
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
padding-bottom: 28rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
padding: 0 24rpx;
|
||||
border: 2rpx solid #e6e7ea;
|
||||
border-radius: 16rpx;
|
||||
font-size: 28rpx;
|
||||
color: #1f2329;
|
||||
background-color: #f7f8fa;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 76rpx;
|
||||
border-radius: 999rpx;
|
||||
background-color: #f5f6f8;
|
||||
|
||||
&--primary {
|
||||
background-color: #2b8fff;
|
||||
}
|
||||
|
||||
/* 名称为空或提交中:主按钮置灰(仍可点,由 onConfirm 拦截) */
|
||||
&--muted {
|
||||
background-color: #b9d6ff;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn-text {
|
||||
font-size: 28rpx;
|
||||
color: #5c626b;
|
||||
|
||||
&--primary {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件页顶部导航栏:「返回箭头 + 居中标题 + 右侧两个显隐开关」。
|
||||
* 文件页顶部导航栏:「返回箭头 + 居中标题 + 传输入口 + 右侧两个显隐开关」。
|
||||
* 根目录不显示返回(showBack=false);标题两侧等宽占位保证真居中。
|
||||
* 右侧 search/grid 两个图标分别切换页面里搜索框与快捷入口区的显隐,
|
||||
* 点亮(蓝)= 对应区域显示,置灰 = 隐藏;排序按钮在 file-tab-filter 工具区。
|
||||
* 传输图标是跳转入口(带进行中计数角标),不参与显隐切换;字形用自绘 SVG(transferGlyph)。
|
||||
* 纯展示组件:不自己做导航与显隐逻辑,按钮语义一律 emit 给页面决定。
|
||||
* 顶栏配色跟随截图(白底黑字),不并入 app-page 的绿色 u-navbar,故页面用 hide-nav 让位。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { TRANSFER_ACTIVE_COLOR, transferGlyph } from "@/utils/transferGlyph";
|
||||
|
||||
const props = defineProps({
|
||||
/** 页面标题(页面用 t() 翻译好后传进来) */
|
||||
title: {
|
||||
@@ -28,13 +32,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
/** 进行中上传任务数:>0 时传输图标点亮并挂数字角标 */
|
||||
transferring: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "back"): void;
|
||||
(e: "toggle-search"): void;
|
||||
(e: "toggle-quick"): void;
|
||||
(e: "transfer"): void;
|
||||
}>();
|
||||
|
||||
/** 传输字形:有进行中任务点亮(iconfont 主色蓝),空闲置灰 */
|
||||
const transferIcon = computed(() => transferGlyph(props.transferring ? TRANSFER_ACTIVE_COLOR : "#8f959e"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,6 +61,13 @@ const emit = defineEmits<{
|
||||
{{ props.title }}
|
||||
</text>
|
||||
<view class="page-navbar__side page-navbar__side--right">
|
||||
<!-- 传输列表入口(常显):有进行中任务时点亮 + 数字角标 -->
|
||||
<view class="page-navbar__btn page-navbar__btn--transfer" @tap="emit('transfer')">
|
||||
<image class="page-navbar__transfer-img" :src="transferIcon" mode="aspectFit" />
|
||||
<text v-if="props.transferring" class="page-navbar__badge">
|
||||
{{ props.transferring > 99 ? "99+" : props.transferring }}
|
||||
</text>
|
||||
</view>
|
||||
<!-- 切换搜索框显隐 -->
|
||||
<view class="page-navbar__btn" @tap="emit('toggle-search')">
|
||||
<u-icon name="search" size="38" :color="props.searchOn ? '#2b8fff' : '#8f959e'" />
|
||||
@@ -72,12 +92,12 @@ const emit = defineEmits<{
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 左右等宽占位(容纳右侧两个按钮):任何状态下标题都严格居中 */
|
||||
/* 左右等宽占位(容纳右侧三个按钮):任何状态下标题都严格居中 */
|
||||
.page-navbar__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
width: 148rpx;
|
||||
width: 208rpx;
|
||||
min-height: 64rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -107,6 +127,31 @@ const emit = defineEmits<{
|
||||
height: 60rpx;
|
||||
}
|
||||
|
||||
/* 传输入口:右上角挂进行中计数角标 */
|
||||
.page-navbar__btn--transfer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 自绘 SVG 传输字形:与同行 u-icon size=38 视觉对齐 */
|
||||
.page-navbar__transfer-img {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.page-navbar__badge {
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
right: 0;
|
||||
min-width: 26rpx;
|
||||
padding: 0 6rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 18rpx;
|
||||
line-height: 26rpx;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
background-color: #f0413e;
|
||||
}
|
||||
|
||||
.page-navbar__title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
@@ -260,6 +260,19 @@ export function useDmsFiles() {
|
||||
await loadFolder(pathStack.value[index].value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接打开指定的目录链(传输页点面包屑跳回时用):
|
||||
* 链是入队时的快照(根→目标),reLaunch 重建页面后拿它恢复位置;
|
||||
* 链尾查不到时 loadFolder 的既有失败态兜底,不另加校验
|
||||
*/
|
||||
async function openTrail(trail: PathNode[]): Promise<void> {
|
||||
if (!trail.length) {
|
||||
return;
|
||||
}
|
||||
pathStack.value = trail.map((node: PathNode) => ({ ...node }));
|
||||
await loadFolder(pathStack.value[pathStack.value.length - 1].value);
|
||||
}
|
||||
|
||||
/** 失败重试:还停在当前目录就重查目录,初始化失败则整个链路重来 */
|
||||
async function retry(): Promise<void> {
|
||||
if (currPath.value) {
|
||||
@@ -278,6 +291,7 @@ export function useDmsFiles() {
|
||||
atRoot,
|
||||
init,
|
||||
openFolder,
|
||||
openTrail,
|
||||
back,
|
||||
goTo,
|
||||
search,
|
||||
|
||||
@@ -59,6 +59,14 @@
|
||||
"actionDetail": "Details",
|
||||
"actionCancel": "Cancel",
|
||||
"actionDone": "Done",
|
||||
"addTitle": "Please Select",
|
||||
"addUpload": "Upload File",
|
||||
"addFolder": "New Folder",
|
||||
"folderPlaceholder": "Enter folder name",
|
||||
"folderCancel": "Cancel",
|
||||
"folderConfirm": "Create",
|
||||
"folderInvalid": "Folder name cannot contain / or \\",
|
||||
"folderCreated": "Folder created",
|
||||
"todoTip": "Coming soon",
|
||||
"todayLabel": "Today",
|
||||
"myCloud": "My Cloud",
|
||||
@@ -66,6 +74,22 @@
|
||||
"loadFailed": "Failed to load, please retry",
|
||||
"retryBut": "Retry"
|
||||
},
|
||||
"TransportPage": {
|
||||
"title": "Transfers",
|
||||
"active": "Active",
|
||||
"done": "Finished",
|
||||
"empty": "No transfer tasks",
|
||||
"queued": "Queued",
|
||||
"uploading": "Uploading",
|
||||
"transferring": "transferring",
|
||||
"success": "Done",
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled",
|
||||
"retry": "Retry",
|
||||
"cancel": "Cancel",
|
||||
"remove": "Remove",
|
||||
"clearDone": "Clear done"
|
||||
},
|
||||
"AdminPage": {
|
||||
"filterBut": "Filter",
|
||||
"filterTitle": "Filter Conditions",
|
||||
|
||||
@@ -59,6 +59,14 @@
|
||||
"actionDetail": "详情",
|
||||
"actionCancel": "取消",
|
||||
"actionDone": "完成",
|
||||
"addTitle": "请选择",
|
||||
"addUpload": "上传文件",
|
||||
"addFolder": "新建文件夹",
|
||||
"folderPlaceholder": "请输入文件夹名称",
|
||||
"folderCancel": "取消",
|
||||
"folderConfirm": "创建",
|
||||
"folderInvalid": "文件夹名称不能包含 / 或 \\",
|
||||
"folderCreated": "文件夹已创建",
|
||||
"todoTip": "功能开发中,敬请期待",
|
||||
"todayLabel": "今天",
|
||||
"myCloud": "我的云盘",
|
||||
@@ -66,6 +74,22 @@
|
||||
"loadFailed": "加载失败,请重试",
|
||||
"retryBut": "重试"
|
||||
},
|
||||
"TransportPage": {
|
||||
"title": "传输列表",
|
||||
"active": "进行中",
|
||||
"done": "已结束",
|
||||
"empty": "暂无传输任务",
|
||||
"queued": "等待中",
|
||||
"uploading": "上传中",
|
||||
"transferring": "项传输中",
|
||||
"success": "已完成",
|
||||
"failed": "失败",
|
||||
"cancelled": "已取消",
|
||||
"retry": "重试",
|
||||
"cancel": "取消",
|
||||
"remove": "删除",
|
||||
"clearDone": "清空完成"
|
||||
},
|
||||
"AdminPage": {
|
||||
"filterBut": "筛选",
|
||||
"filterTitle": "筛选条件",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# 自定义图标:内联 SVG 字形与 iconfont 字体两条路线
|
||||
|
||||
适用范围:`src/utils/transferGlyph.ts`、`selectGlyph.ts`、`folderGlyph.ts`、`sortGlyph.ts`(自绘字形四件套),`src/components/page-navbar`、`file-tab-filter`、`float-add-btn` 及文件页胶囊等使用处。
|
||||
uview-pro 组件接线通则见 [theme.md](./theme.md) 开头的 CSS 变量说明;路由见 [page-routing.md](./page-routing.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么需要自定义图标
|
||||
|
||||
uview-pro 自带 `u-icon` 字体库(`custom-prefix="uicon"`)覆盖常见语义,但「传输列表、新建文件夹、降序排序、多选开关」这类云盘专属语义,内置库里要么没有、要么形不似(如用 `clock` 顶替传输被用户否掉)。项目现采用**内联 SVG 字形**方案:图标从 iconfont.cn 人工挑选,SVG path 提取进代码,`<image>` 渲染,零字体文件、零网络依赖。
|
||||
|
||||
## 2. 字形文件的标准写法(四件套同一模板)
|
||||
|
||||
每个字形一个 `src/utils/xxxGlyph.ts`,固定三段结构:
|
||||
|
||||
```text
|
||||
/**
|
||||
* XX 自定义字形(内联 SVG,不依赖 iconfont 字体文件)。
|
||||
* 字形为用户从 iconfont.cn 挑选的「……」图标,原 fill 色改为按参数注入。
|
||||
*/
|
||||
/* 字形分段存(每个元素一条子路径):单行超长拼接会撞 operator-linebreak/Prettier 对撞 */
|
||||
const XXX_SEGMENTS = [
|
||||
"M860.761163 ...", // 每条 <path> 的 d 一个数组元素
|
||||
"M553.757495 ...",
|
||||
];
|
||||
|
||||
export function xxxGlyph(color: string): string {
|
||||
const d = XXX_SEGMENTS.join("");
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="${color}" d="${d}" /></svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
```
|
||||
|
||||
要点(都是踩过的坑):
|
||||
|
||||
- **fill 参数化**:iconfont 下载的 SVG 色值是写死的(`#1A98FF`/`#999999` 等),一律改成 `${color}` 注入——同一份 path 服务点亮/置灰/反白多态,不用 CSS mask(兼容性最好);
|
||||
- **多 path 合并**:原图多条 `<path>` 的 `d` 拼成一条(本项目挑选的图标 fill 同色,可直接合并);若真有双色图标则需保留多个 path 标签,暂未有需求;
|
||||
- **分段存数组**:`d` 串动辄上千字符,若写成 `const X = "..." + "..."` 多行拼接会触发 ESLint `operator-linebreak` 与编辑器 Prettier 重排的对撞,数组元素逐行存放两者都不动;
|
||||
- **viewBox 原样保留**:不要统一改成 1024 方形(sortGlyph 是 `0 0 1178 1024`),改了图形会变形。
|
||||
|
||||
## 3. 调用点接线
|
||||
|
||||
```text
|
||||
<script setup lang="ts">
|
||||
import { sortGlyph } from "@/utils/sortGlyph";
|
||||
/* 颜色固定,模块级生成一次(computed 仅在颜色随状态变化时用,如 page-navbar 的蓝灰切换) */
|
||||
const sortIconDark = sortGlyph("#1f2329");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<image class="xxx__tool-img" :src="sortIconDark" mode="aspectFit" />
|
||||
</template>
|
||||
```
|
||||
|
||||
- 颜色**固定** → 模块级 `const`;颜色**随状态** → `computed`(见 `page-navbar` 的 `transferIcon`:有任务 `TRANSFER_ACTIVE_COLOR`,闲置 `#8f959e`);
|
||||
- 组件顶层 `defineProps/defineEmits` 之前不许插业务语句(`vue/define-macros-order`),字形常量一律放 `defineEmits` 之后;
|
||||
- 尺寸按**视觉重量**给,不是按标称值:
|
||||
- 图形撑满 viewBox 的(selectGlyph/sortGlyph)→ 与同行 `u-icon size=36` 同框(36×36rpx;sort 因 1.15:1 非方形给 38×33rpx);
|
||||
- 图形四周留白多的(folderGlyph 内容只占约 78%)→ 放大框到 46×46rpx 补偿,否则肉眼显小(用户已反馈过一次)。
|
||||
|
||||
## 4. 现有字形清单
|
||||
|
||||
| 文件 | 字形 | 使用处 | 色态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `utils/transferGlyph.ts` | 文档+上下传输箭头 | navbar 传输入口、文件页悬浮胶囊 | 点亮 `#1A98FF`(导出 `TRANSFER_ACTIVE_COLOR`)/ 置灰 / 白 |
|
||||
| `utils/selectGlyph.ts` | 方框+右上箭头 | 工具栏多选开关(未进入态) | 深 `#1f2329`;进入后仍用 `u-icon close-circle` 蓝 |
|
||||
| `utils/folderGlyph.ts` | 文件夹+加号 | 工具栏新建文件夹 | 深 `#1f2329` |
|
||||
| `utils/sortGlyph.ts` | 三横线+下箭头 | 工具栏排序 | 深 `#1f2329` |
|
||||
|
||||
## 5. 换/加一个图标的固定步骤
|
||||
|
||||
1. 从 iconfont.cn 挑图,复制 SVG(含 `<path d>` 与 `viewBox`);
|
||||
2. 按第 2 节模板新建(或替换)`src/utils/xxxGlyph.ts`;
|
||||
3. 调用点把 `<u-icon>` 换成 `<image :src mode="aspectFit">`,接字形常量;
|
||||
4. 对照第 3 节做尺寸视觉校正(留意原图留白比例与 viewBox 是否方形);
|
||||
5. 验证:`npx vue-tsc --noEmit -p tsconfig.json` 退出码 0 + 浏览器 F5 目验同行图标大小一致。
|
||||
|
||||
## 6. 备选路线:uview-pro 官方 customPrefix 字体方案
|
||||
|
||||
官方文档 `uviewpro.cn/zh/guide/customIcon.html`。适合**一次引入大批图标且需要小程序端**的场景(小程序 `<image>` 不认 svg data URI,字体方案全端通吃):
|
||||
|
||||
1. 登录 iconfont.cn → 选图标加入项目 → 下载字体包(`iconfont.css` + `woff/ttf`);
|
||||
2. 放 `src/static/font/`,改 `iconfont.css` 里 `@font-face` 的 `src` 路径(H5 建议 base64 内嵌,App/小程序指相对路径);
|
||||
3. `App.vue` 里 `@import` 该 css(相对路径);
|
||||
4. 使用:`<u-icon custom-prefix="my-icon" name="传输" />`——类名规则是 `custom-prefix + '-' + name`,非 `uicon` 前缀时组件直接挂 prefix 类。
|
||||
|
||||
当前项目图标量少(4 个)且主战场 H5/App,未引入字体包;图标超过 ~10 个或要上微信/支付宝小程序时再迁,迁移只动调用点标签,`xxxGlyph.ts` 里的 path 可留作 css 生成源。
|
||||
|
||||
## 7. 相关坑位备忘
|
||||
|
||||
- `u-icon` 的 `name` 只认 uview-pro 字体库类名,写陌生名字**静默空白无告警**——新语义先翻 `node_modules/uview-pro/iconfont.css` 搜 `uicon-` 确认存在,没有就走本教程;
|
||||
- 组件 prop 名以 `node_modules` 内 `types.ts` 为准(如 `u-line-progress` 是 `percent` 不是 `percentage`),写错同样静默失效;
|
||||
- 字形改动后 H5 需 F5:data URI 在模块级常量里,HMR 不一定重跑 setup。
|
||||
@@ -43,6 +43,10 @@
|
||||
},
|
||||
{
|
||||
"path": "pages/tab/mine"
|
||||
},
|
||||
// 传输列表:uploadQueue store 的视图页,选文件上传后自动跳入
|
||||
{
|
||||
"path": "pages/transport/transport"
|
||||
}
|
||||
],
|
||||
// 全局配置
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
* file-breadcrumb 目录面包屑(仅子目录态:左起排列,放不下折成 根+…+尾部段)
|
||||
* search-input 搜索框(v-model 即时过滤,确认键触发服务端搜索)
|
||||
* quick-entry 快捷功能入口(横向滚动图标组)
|
||||
* file-tab-filter 分类标签 + 排序 + 视图切换 + 多选开关
|
||||
* file-tab-filter 分类标签 + 新建文件夹 + 排序 + 视图切换 + 多选开关
|
||||
* file-grid-list 文件网格/列表卡片
|
||||
* float-add-btn 右下角悬浮加号
|
||||
* float-add-btn 右下角悬浮加号(直接选文件上传)
|
||||
* 数据来自后端 DMS 接口(composables/useDmsFiles,调用链对齐旧 WebVue DmsFileEnquiry.vue);
|
||||
* 组件契约(data/custom/fileBrowser.ts)不关心数据源,演示数据到真实接口只动页面层。
|
||||
* 目录导航:点文件夹下钻;子目录态隐藏底部 tabbar,返回箭头/面包屑退层级。
|
||||
@@ -18,24 +18,38 @@ import type { FileCategory, FileItem, FileKind, FileTabItem, QuickEntryItem } fr
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useAuthGuard, useDmsFiles, useThemeGradient } from "@/composables";
|
||||
import { DmsFileEntity } from "@/data/auto/dmsFileEntity";
|
||||
import { pageUrl } from "@/router/routes";
|
||||
import dmsFileService from "@/service/master/dmsFileService";
|
||||
import { useUploadQueueStore } from "@/stores/uploadQueue";
|
||||
import { chooseFiles } from "@/utils/chooseFiles";
|
||||
import { transferGlyph } from "@/utils/transferGlyph";
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
// 页面底渐变跟随主题(派生逻辑在 composables/useThemeGradient,多页共用)
|
||||
const { themeBackground } = useThemeGradient();
|
||||
|
||||
const { files, loading, errorText, pathStack, atRoot, init, retry, openFolder, back, goTo, search } = useDmsFiles();
|
||||
const { files, loading, errorText, pathStack, atRoot, init, openTrail, retry, openFolder, back, goTo, search } = useDmsFiles();
|
||||
|
||||
// 首次显示拉根目录,之后每次回到页面只刷新当前目录(重进 tab 不该丢位置重新拉全链)
|
||||
// 上传队列(全局 store):入队在本页、胶囊读它的进行中计数、守卫回调消费它的面包屑导航请求
|
||||
const uploadQueue = useUploadQueueStore();
|
||||
|
||||
// 首次显示拉根目录,之后每次回到页面只刷新当前目录(重进 tab 不该丢位置重新拉全链);
|
||||
// 回页末尾消费传输页的面包屑导航请求(reLaunch 重建后恢复到目标目录)
|
||||
let started = false;
|
||||
useAuthGuard(() => {
|
||||
useAuthGuard(async () => {
|
||||
if (started) {
|
||||
retry();
|
||||
return;
|
||||
await retry();
|
||||
}
|
||||
else {
|
||||
started = true;
|
||||
await init();
|
||||
}
|
||||
const trail = uploadQueue.takeNav();
|
||||
if (trail) {
|
||||
await openTrail(trail);
|
||||
}
|
||||
started = true;
|
||||
init();
|
||||
});
|
||||
|
||||
// ===== 页面状态(全部单向下发,组件只上报意图) =====
|
||||
@@ -102,11 +116,6 @@ function toast(title: string): void {
|
||||
uni.showToast({ title, icon: "none" });
|
||||
}
|
||||
|
||||
/** 待接入真实功能时的统一占位提示 */
|
||||
function todoTip(): void {
|
||||
toast(t("FilePage.todoTip"));
|
||||
}
|
||||
|
||||
// ===== 导航栏 / 面包屑 =====
|
||||
function onToggleSearch(): void {
|
||||
showSearch.value = !showSearch.value;
|
||||
@@ -334,20 +343,83 @@ function onSheetAction(key: string, file: FileItem): void {
|
||||
toast(`${file.name} · ${key} · ${t("FilePage.todoTip")}`);
|
||||
}
|
||||
|
||||
// 新建文件夹命名对话框(CreateDmsFolder 在途标记防连点)
|
||||
const folderDialogVisible = ref(false);
|
||||
const folderSubmitting = ref(false);
|
||||
|
||||
// 上传队列状态见文件头部的 uploadQueue store
|
||||
|
||||
/** 悬浮「+」:新建文件夹已拆到工具栏图标,这里直接选文件上传 */
|
||||
function onAdd(): void {
|
||||
todoTip();
|
||||
pickAndUpload();
|
||||
}
|
||||
|
||||
/** 工具栏「文件夹+」图标:弹新建文件夹命名对话框 */
|
||||
function onNewFolder(): void {
|
||||
folderDialogVisible.value = true;
|
||||
}
|
||||
|
||||
/** 名称限制对齐后端目录模型:不能含路径分隔符(FolderLocalName 直接参与路径拼接) */
|
||||
async function onCreateFolder(name: string): Promise<void> {
|
||||
if (/[\\/]/.test(name)) {
|
||||
toast(t("FilePage.folderInvalid"));
|
||||
return;
|
||||
}
|
||||
folderSubmitting.value = true;
|
||||
try {
|
||||
const entity = new DmsFileEntity();
|
||||
entity.FileName = name;
|
||||
entity.FilePath = pathStack.value[pathStack.value.length - 1]?.value ?? "";
|
||||
await dmsFileService.CreateDmsFolder(entity);
|
||||
folderDialogVisible.value = false;
|
||||
toast(t("FilePage.folderCreated"));
|
||||
// 新建完直接刷当前目录,不等切回页面才重查
|
||||
retry();
|
||||
}
|
||||
catch (error: any) {
|
||||
// 业务异常主拦截器已弹模态,这里只兜网络层失败
|
||||
if (error?.errMsg) {
|
||||
toast(error.errMsg);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
folderSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 选文件→入队(快照面包屑链供传输页展示/跳回)→跳传输列表;之后用户跳走不影响上传 */
|
||||
async function pickAndUpload(): Promise<void> {
|
||||
try {
|
||||
const picked = await chooseFiles();
|
||||
if (!picked.length) {
|
||||
return;
|
||||
}
|
||||
uploadQueue.addFiles(picked, [...pathStack.value]);
|
||||
uni.navigateTo({ url: pageUrl("transport") });
|
||||
}
|
||||
catch (error: any) {
|
||||
toast(error?.errMsg || error?.message || t("FilePage.todoTip"));
|
||||
}
|
||||
}
|
||||
|
||||
function goTransport(): void {
|
||||
uni.navigateTo({ url: pageUrl("transport") });
|
||||
}
|
||||
|
||||
/** 胶囊内的自绘传输字形(白字蓝底,颜色固定,模块级生成一次) */
|
||||
const transferIconWhite = transferGlyph("#ffffff");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 操作面板/多选操作栏展开时收起 tabbar:tabbar 在页面 fixed 层叠上下文之外,
|
||||
弹层 z-index 再高也压不住它,直接隐藏最干净(主流云盘同款交互) -->
|
||||
<app-page :hide-nav="true" :show-tabbar="atRoot && !actionFile && !selectMode">
|
||||
<app-page :hide-nav="true" :show-tabbar="atRoot && !actionFile && !selectMode && !folderDialogVisible">
|
||||
<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" />
|
||||
<page-navbar :title="navTitle" :show-back="!atRoot" :search-on="showSearch" :quick-on="showQuick"
|
||||
:transferring="uploadQueue.uploadingCount" @back="onBack" @toggle-search="onToggleSearch"
|
||||
@toggle-quick="onToggleQuick" @transfer="goTransport" />
|
||||
<!-- 子目录态才有的路径条:白色衬底包住面包屑(外面不再是灰页面底),左起全量显示,溢出折叠 -->
|
||||
<view v-if="!atRoot" class="file-page__crumb">
|
||||
<file-breadcrumb :items="pathStack" @nav="onCrumbNav" />
|
||||
@@ -360,7 +432,8 @@ function onAdd(): void {
|
||||
<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" />
|
||||
@change="onCategoryChange" @sort="onSort" @toggle-view="onToggleView" @toggle-select="onToggleSelect"
|
||||
@new-folder="onNewFolder" />
|
||||
<view v-if="selectMode" class="file-page__selection">
|
||||
<text class="file-page__selection-text">
|
||||
{{ t("FilePage.selectedCount", { count: selectedIds.length }) }}
|
||||
@@ -410,8 +483,17 @@ function onAdd(): void {
|
||||
</view>
|
||||
<!-- 多选态加号让位底部操作栏,避免两个悬浮物打架 -->
|
||||
<float-add-btn v-if="!selectMode" @click="onAdd" />
|
||||
<!-- 上传中悬浮胶囊:任意目录下一键回传输列表(任务在 store 里,跳走不中断) -->
|
||||
<view v-if="uploadQueue.uploadingCount && !selectMode" class="file-page__transfer" @click="goTransport">
|
||||
<image class="file-page__transfer-img" :src="transferIconWhite" mode="aspectFit" />
|
||||
<text class="file-page__transfer-text">
|
||||
{{ uploadQueue.uploadingCount }} {{ t("TransportPage.transferring") }}
|
||||
</text>
|
||||
</view>
|
||||
<file-action-sheet :visible="!!actionFile" :file="actionFile" @close="actionFile = null"
|
||||
@action="onSheetAction" />
|
||||
<folder-name-dialog :visible="folderDialogVisible" :submitting="folderSubmitting"
|
||||
@close="folderDialogVisible = false" @confirm="onCreateFolder" />
|
||||
<!-- 多选底部操作栏:常驻非模态(无遮罩),勾选与动作可交替进行;
|
||||
动作集是旧系统多选子集——共享/发布按同媒体类型条件,重命名/移动仅单选 -->
|
||||
<view v-if="selectMode" class="file-page__actions">
|
||||
@@ -493,6 +575,32 @@ function onAdd(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/* 上传中悬浮胶囊:左下角常驻(避开右侧可拖动加号),点击回传输列表 */
|
||||
.file-page__transfer {
|
||||
position: fixed;
|
||||
bottom: 220rpx;
|
||||
left: 24rpx;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
padding: 16rpx 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background-color: rgba(41, 121, 255, 0.9);
|
||||
box-shadow: 0 6rpx 16rpx rgba(41, 121, 255, 0.35);
|
||||
}
|
||||
|
||||
.file-page__transfer-text {
|
||||
font-size: 24rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 胶囊内自绘传输字形:与原文案图标同尺寸(26rpx) */
|
||||
.file-page__transfer-img {
|
||||
width: 26rpx;
|
||||
height: 26rpx;
|
||||
}
|
||||
|
||||
.file-page__selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 传输列表页:uploadQueue store 的视图层。
|
||||
*
|
||||
* 任务状态全部住在 store 里,本页只渲染与派发操作——上传中切走、回来看进度不断;
|
||||
* 与旧系统 OperatorStation 的 upload-progress/success/interrupted 事件链同一语义,
|
||||
* 只是把「页面持有状态」翻转成了「store 持有状态」。
|
||||
*/
|
||||
import type { UploadItem } from "@/stores/uploadQueue";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { useAuthGuard, useThemeGradient } from "@/composables";
|
||||
import { pageUrl } from "@/router/routes";
|
||||
import { useUploadQueueStore } from "@/stores/uploadQueue";
|
||||
|
||||
const { t } = useLocale();
|
||||
const { themeBackground } = useThemeGradient();
|
||||
const uploadQueue = useUploadQueueStore();
|
||||
|
||||
// 本页无后端数据要拉,守卫只做登录拦截(未登录直接送去登录页)
|
||||
useAuthGuard();
|
||||
|
||||
/** 字节数可读化:0/拿不到显示 -- */
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes < 0) {
|
||||
return "--";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let index = 0;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${value.toFixed(index === 0 || value >= 100 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
/** 状态行:上传中带速度与百分比,失败附错误摘要 */
|
||||
function statusLine(item: UploadItem): string {
|
||||
switch (item.status) {
|
||||
case "queued":
|
||||
return t("TransportPage.queued");
|
||||
case "uploading":
|
||||
return `${t("TransportPage.uploading")} · ${formatSize(item.speed)}/s · ${item.progress}%`;
|
||||
case "success":
|
||||
return t("TransportPage.success");
|
||||
case "cancelled":
|
||||
return t("TransportPage.cancelled");
|
||||
default:
|
||||
return `${t("TransportPage.failed")}${item.error ? ` · ${item.error}` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按扩展名挑图标(字形均已核实存在于 uview-pro iconfont) */
|
||||
function kindIcon(name: string): string {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
if (["jpg", "jpeg", "png", "gif", "webp", "bmp", "heic"].includes(ext)) {
|
||||
return "photo";
|
||||
}
|
||||
if (["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) {
|
||||
return "play-circle";
|
||||
}
|
||||
if (["mp3", "wav", "m4a", "aac", "flac"].includes(ext)) {
|
||||
return "volume";
|
||||
}
|
||||
return "file-text";
|
||||
}
|
||||
|
||||
function statusColor(item: UploadItem): string {
|
||||
if (item.status === "failed") {
|
||||
return "#f0413e";
|
||||
}
|
||||
if (item.status === "success") {
|
||||
return "#19be6b";
|
||||
}
|
||||
return "#8f959e";
|
||||
}
|
||||
|
||||
/**
|
||||
* 点条目面包屑:登记目标目录链后 reLaunch 回文件 tab(与 app-tabbar 同款跳法);
|
||||
* 点任意段都去该任务的末段目录(入队时的目标目录),文件页 onShow 消费请求后恢复位置
|
||||
*/
|
||||
function goTargetFolder(item: UploadItem): void {
|
||||
uploadQueue.requestNav(item.targetTrail);
|
||||
uni.reLaunch({ url: pageUrl("fileTab") });
|
||||
}
|
||||
|
||||
/** 返回键:F5 刷新后历史栈已空,navigateBack 无效——无栈时兜底回文件 tab(本页就是文件页的附属页) */
|
||||
function onBack(): void {
|
||||
if (getCurrentPages().length > 1) {
|
||||
uni.navigateBack();
|
||||
return;
|
||||
}
|
||||
uni.reLaunch({ url: pageUrl("fileTab") });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<app-page :nav-title="t('TransportPage.title')" :custom-back="onBack">
|
||||
<view class="transport-page" :style="{ background: themeBackground }">
|
||||
<view v-if="!uploadQueue.items.length" class="transport-page__empty">
|
||||
<u-empty mode="list" :text="t('TransportPage.empty')" icon-size="120" />
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- 进行中队(排队 + 上传中) -->
|
||||
<view v-if="uploadQueue.activeItems.length" class="transport-page__group">
|
||||
<text class="transport-page__group-title">
|
||||
{{ t("TransportPage.active") }} ({{ uploadQueue.activeItems.length }})
|
||||
</text>
|
||||
<view v-for="item in uploadQueue.activeItems" :key="item.id" class="transport-item">
|
||||
<view class="transport-item__row">
|
||||
<u-icon :name="kindIcon(item.name)" size="44" color="#2b8fff" />
|
||||
<view class="transport-item__main">
|
||||
<text class="transport-item__name">
|
||||
{{ item.name }}
|
||||
</text>
|
||||
<text class="transport-item__status">
|
||||
{{ statusLine(item) }} · {{ formatSize(item.size) }}
|
||||
</text>
|
||||
<u-line-progress v-if="item.status === 'uploading'" :percent="item.progress" height="8"
|
||||
:show-percent="false" active-color="#2b8fff" />
|
||||
</view>
|
||||
<view class="transport-item__btn" @tap="uploadQueue.cancel(item.id)">
|
||||
{{ t("TransportPage.cancel") }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.targetTrail.length" class="transport-item__crumb" @tap="goTargetFolder(item)">
|
||||
<file-breadcrumb :items="item.targetTrail" show-single />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 已结束队(成功 / 失败 / 取消) -->
|
||||
<view v-if="uploadQueue.doneItems.length" class="transport-page__group">
|
||||
<view class="transport-page__group-head">
|
||||
<text class="transport-page__group-title">
|
||||
{{ t("TransportPage.done") }} ({{ uploadQueue.doneItems.length }})
|
||||
</text>
|
||||
<text class="transport-page__clear" @tap="uploadQueue.clearDone()">
|
||||
{{ t("TransportPage.clearDone") }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-for="item in uploadQueue.doneItems" :key="item.id" class="transport-item">
|
||||
<view class="transport-item__row">
|
||||
<u-icon :name="kindIcon(item.name)" size="44" :color="statusColor(item)" />
|
||||
<view class="transport-item__main">
|
||||
<text class="transport-item__name">
|
||||
{{ item.name }}
|
||||
</text>
|
||||
<text class="transport-item__status" :style="{ color: statusColor(item) }">
|
||||
{{ statusLine(item) }} · {{ formatSize(item.size) }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="item.status !== 'success'" class="transport-item__btn" @tap="uploadQueue.retry(item.id)">
|
||||
{{ t("TransportPage.retry") }}
|
||||
</view>
|
||||
<view class="transport-item__btn transport-item__btn--plain" @tap="uploadQueue.remove(item.id)">
|
||||
{{ t("TransportPage.remove") }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.targetTrail.length" class="transport-item__crumb" @tap="goTargetFolder(item)">
|
||||
<file-breadcrumb :items="item.targetTrail" show-single />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</app-page>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.transport-page {
|
||||
min-height: calc(100vh - var(--window-top, 0px));
|
||||
padding: 24rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
|
||||
&__empty {
|
||||
padding-top: 200rpx;
|
||||
}
|
||||
|
||||
&__group {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
&__group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__group-title {
|
||||
display: block;
|
||||
padding: 8rpx 8rpx 16rpx;
|
||||
font-size: 26rpx;
|
||||
color: #8f959e;
|
||||
}
|
||||
|
||||
&__clear {
|
||||
padding: 8rpx 8rpx 16rpx;
|
||||
font-size: 26rpx;
|
||||
color: #2979ff;
|
||||
}
|
||||
}
|
||||
|
||||
.transport-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background-color: #ffffff;
|
||||
|
||||
/* 行主体:图标 + 文本区 + 动作按钮横向排列(面包屑另占一行) */
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
/* 目标目录面包屑行:整行可点,点任意段都跳回文件页的目标目录(单段也走同款卡片) */
|
||||
&__crumb {
|
||||
padding-left: 64rpx;
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 28rpx;
|
||||
color: #1f2329;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 22rpx;
|
||||
color: #8f959e;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
flex-shrink: 0;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 24rpx;
|
||||
color: #2979ff;
|
||||
background-color: #eaf4ff;
|
||||
|
||||
&--plain {
|
||||
color: #8f959e;
|
||||
background-color: #f5f6f8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -24,6 +24,8 @@ export const PAGE_PATHS = {
|
||||
mediaTab: "pages/tab/media",
|
||||
shareTab: "pages/tab/share",
|
||||
mineTab: "pages/tab/mine",
|
||||
// 传输列表(短地址 /transport):与文件页同一功能域
|
||||
transport: "pages/transport/transport",
|
||||
} as const;
|
||||
|
||||
export type PageName = keyof typeof PAGE_PATHS;
|
||||
@@ -76,6 +78,7 @@ export const PAGE_PERMISSIONS: Partial<Record<PageName, PermissionNames>> = {
|
||||
mediaTab: "DmsFile",
|
||||
shareTab: "DmsFile",
|
||||
mineTab: "DmsFile",
|
||||
transport: "DmsFile",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,3 +16,4 @@ export default pinia;
|
||||
|
||||
export * from "./loginProfile";
|
||||
export * from "./tabbar";
|
||||
export * from "./uploadQueue";
|
||||
@@ -0,0 +1,467 @@
|
||||
import type { PathNode } from "@/data/custom/fileBrowser";
|
||||
import type { PickedFile } from "@/utils/chooseFiles";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
baseUrl,
|
||||
bearerAuth,
|
||||
extractBusinessError,
|
||||
} from "@/common/http.interceptor";
|
||||
|
||||
/**
|
||||
* 上传队列 store:任务状态的唯一持有者,传输列表页只是它的视图。
|
||||
*
|
||||
* 任务住在 store(而非页面 setup)里,所以上传中随便跳页、切 tab 都不会中断;
|
||||
* H5 刷新 / 杀进程仍会断(JS 运行时销毁,无解),故不做持久化——
|
||||
* 重启后本地文件路径也已失效,持久化反而是脏数据。
|
||||
*
|
||||
* 后端口径(CloudBuilder DmsFileService.UploadDmsFile,对齐旧 WebVue FileUpload):
|
||||
* multipart 的 form 字段名就是原始文件名(OrgFileName 取它),
|
||||
* 目标目录走请求头 FilePath,认证头 Authorization 由请求层令牌拼装。
|
||||
*/
|
||||
|
||||
export interface UploadItem {
|
||||
id: string;
|
||||
/** 文件名,同时是 multipart form 字段名(后端按它落 OrgFileName) */
|
||||
name: string;
|
||||
/** 本地路径:H5 为 blob: URL,App 为 file: 路径 */
|
||||
filePath: string;
|
||||
/** 字节数,取不到时为 0 */
|
||||
size: number;
|
||||
/** H5 原生 File 对象:有则走 XHR+FormData 通道(可控 multipart filename) */
|
||||
raw?: unknown;
|
||||
/** DMS 目标目录(FilePath 请求头),取 targetTrail 末段,入队时由文件页快照 */
|
||||
targetPath: string;
|
||||
/** 从根到目标目录的面包屑链快照:传输页展示 + 点击跳回文件页对应目录 */
|
||||
targetTrail: PathNode[];
|
||||
/** 0-100 */
|
||||
progress: number;
|
||||
/** 字节/秒,通道未回传进度时为 0 */
|
||||
speed: number;
|
||||
status: "queued" | "uploading" | "success" | "failed" | "cancelled";
|
||||
error: string;
|
||||
/** 进入终态的时刻(ms):已结束组按它倒序,刚完成的排顶部;未结束为 0 */
|
||||
finishedAt: number;
|
||||
}
|
||||
|
||||
/** 并发 3(旧系统 FileUpload 默认 4,移动端蜂窝网络下保守一档) */
|
||||
const MAX_PARALLEL = 3;
|
||||
|
||||
/** 可中止句柄:uni 的 UploadTask 与 XHR 都满足这个最小面 */
|
||||
interface UploadHandle {
|
||||
abort: () => void;
|
||||
}
|
||||
|
||||
/** 传输句柄不可序列化,放模块级 Map,不进 store state */
|
||||
const taskHandles = new Map<string, UploadHandle>();
|
||||
|
||||
let seq = 0;
|
||||
|
||||
/**
|
||||
* 分片文件名尾巴:后端 PackUploadDmsFile 对 multipart 的 filename 做 Split('-') 取倒数两段
|
||||
* 判定分片序号/总数(原始文件名没有这个结构会直接 IndexOutOfRange,实测 401 后的第二个坑)。
|
||||
* 完整格式 uid-序号-总数.ext 在 xhrUpload 里逐片拼装;单片(-1-1)后端直接落库,
|
||||
* 多片由最后一片请求触发服务端合并+备份+缩略图(均为旧系统 FileUpload 同款路径)。
|
||||
*/
|
||||
function extOf(fileName: string): string {
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
return dot > 0 ? fileName.slice(dot) : ".ext";
|
||||
}
|
||||
|
||||
/** 分片大小:内网 3MB / 公网 1MB(旧系统 FileUpload 同口径;开发构建视为内网) */
|
||||
function resolveChunkSize(): number {
|
||||
const publicNet = /localhost|127\.|192\.168\.|10\./.test(baseUrl);
|
||||
const intranet = import.meta.env.DEV || publicNet;
|
||||
return (intranet ? 3 : 1) * 1024 * 1024;
|
||||
}
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
/**
|
||||
* App 侧 UploadTask 的 multipart filename 取 filePath 的 basename,无法直接满后端
|
||||
* uid-序号-总数.ext 协议(同 H5 踩过的 IndexOutOfRange),先把文件复制成
|
||||
* _doc/uploads/uid-1-1.ext 再传;真机验证前不引入分片循环(读不了本地字节切片)。
|
||||
* 项目 types 未含 plus 声明,本地宽松兑底(仅 App 运行时真实存在)。
|
||||
*/
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
declare const plus: any;
|
||||
// #endif
|
||||
|
||||
function prepareAppFilePath(item: UploadItem): Promise<string> {
|
||||
// #ifdef APP-PLUS
|
||||
const chunkName = `uni-${Date.now()}-1-1${extOf(item.name)}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
plus.io.resolveLocalFileSystemURL(
|
||||
"_doc/",
|
||||
(root: any) => {
|
||||
root.getDirectory(
|
||||
"uploads",
|
||||
{ create: true },
|
||||
(dir: any) => {
|
||||
plus.io.resolveLocalFileSystemURL(
|
||||
item.filePath,
|
||||
(entry: any) => {
|
||||
entry.copyTo(
|
||||
dir,
|
||||
chunkName,
|
||||
(moved: any) => resolve(moved.toLocalURL()),
|
||||
reject,
|
||||
);
|
||||
},
|
||||
reject,
|
||||
);
|
||||
},
|
||||
reject,
|
||||
);
|
||||
},
|
||||
reject,
|
||||
);
|
||||
});
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
return Promise.resolve(item.filePath);
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 解上传响应体:成功回空串/纯文本,只有异常体是 JSON 对象 */
|
||||
function parseUploadBody(raw: unknown): any {
|
||||
if (raw && typeof raw === "object") {
|
||||
return raw;
|
||||
}
|
||||
const text = String(raw ?? "").trim();
|
||||
if (!text.startsWith("{")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
// 注释卡在 } 与 catch 之间:阻断编辑器把 } catch { 折回同行的 Prettier 规范形(与 Stroustrup brace-style 对撞)
|
||||
// eslint-disable-next-line brace-style
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const useUploadQueueStore = defineStore(
|
||||
"uploadQueue",
|
||||
() => {
|
||||
const items = ref<UploadItem[]>([]);
|
||||
|
||||
// ===== 派生视图 =====
|
||||
/** 活跃态(排队/上传中):分组、角标、beforeunload 三处共用同一判定 */
|
||||
function isActive(item: UploadItem): boolean {
|
||||
return item.status === "queued" || item.status === "uploading";
|
||||
}
|
||||
|
||||
/** 进行中(含排队):传输页上半组 */
|
||||
const activeItems = computed(() => items.value.filter(isActive));
|
||||
/** 已结束(成功/失败/取消):传输页下半组,刚完成的排顶部方便找到 */
|
||||
const doneItems = computed(() => {
|
||||
const done = items.value.filter((item: UploadItem) => !isActive(item));
|
||||
return done.sort((a, b) => b.finishedAt - a.finishedAt);
|
||||
});
|
||||
/** 进行中数量:文件页胶囊角标用 */
|
||||
const uploadingCount = computed(() => activeItems.value.length);
|
||||
|
||||
function findItem(id: string): UploadItem | undefined {
|
||||
return items.value.find((item: UploadItem) => item.id === id);
|
||||
}
|
||||
|
||||
// ===== 队列驱动 =====
|
||||
/** 补位式调度:不满并发上限就把排队任务逐个点着 */
|
||||
function pump(): void {
|
||||
let busy = items.value.filter(
|
||||
(item: UploadItem) => item.status === "uploading",
|
||||
).length;
|
||||
while (busy < MAX_PARALLEL) {
|
||||
const next = items.value.find(
|
||||
(item: UploadItem) => item.status === "queued",
|
||||
);
|
||||
if (!next) {
|
||||
break;
|
||||
}
|
||||
runTask(next);
|
||||
busy += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function runTask(item: UploadItem): void {
|
||||
item.status = "uploading";
|
||||
item.progress = 0;
|
||||
item.speed = 0;
|
||||
item.error = "";
|
||||
taskHandles.set(item.id, startChannel(item));
|
||||
}
|
||||
|
||||
/** 通道分流:H5 拿得到原生 File 就走 XHR(可控 filename,完整复刻旧协议);其余端走 uni.uploadFile */
|
||||
function startChannel(item: UploadItem): UploadHandle {
|
||||
// #ifdef H5
|
||||
if (item.raw) {
|
||||
return xhrUpload(item, item.raw as Blob);
|
||||
}
|
||||
// #endif
|
||||
return uniUpload(item);
|
||||
}
|
||||
|
||||
/** 结果统一收口:HTTP 200 不等于业务成功(后端失败回 .NET 异常体) */
|
||||
function applyResult(
|
||||
item: UploadItem,
|
||||
statusCode: number,
|
||||
data: unknown,
|
||||
): void {
|
||||
const httpOk = statusCode >= 200 && statusCode < 300;
|
||||
const problem = httpOk
|
||||
? extractBusinessError(parseUploadBody(data))
|
||||
: `HTTP ${statusCode}`;
|
||||
item.status = problem ? "failed" : "success";
|
||||
item.progress = problem ? item.progress : 100;
|
||||
item.error = problem;
|
||||
finish(item);
|
||||
}
|
||||
|
||||
/** H5 通道:XHR + 手写 FormData,按旧协议分片串行(字段名=原始文件名,filename=uid-序号-总数.ext) */
|
||||
function xhrUpload(item: UploadItem, blob: Blob): UploadHandle {
|
||||
let current: XMLHttpRequest | null = null;
|
||||
let aborted = false;
|
||||
const chunkSize = resolveChunkSize();
|
||||
const total = Math.max(Math.ceil(blob.size / chunkSize), 1);
|
||||
const uid = `uni-${Date.now()}`;
|
||||
const startedAt = Date.now();
|
||||
let doneBytes = 0;
|
||||
|
||||
const sendChunk = (index: number): void => {
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
const chunk = blob.slice(
|
||||
(index - 1) * chunkSize,
|
||||
Math.min(index * chunkSize, blob.size),
|
||||
);
|
||||
const xhr = new XMLHttpRequest();
|
||||
current = xhr;
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
item.name,
|
||||
chunk,
|
||||
`${uid}-${index}-${total}${extOf(item.name)}`,
|
||||
);
|
||||
xhr.open("POST", `${baseUrl}/DmsFile/UploadDmsFile`);
|
||||
xhr.setRequestHeader("Authorization", bearerAuth());
|
||||
xhr.setRequestHeader("FilePath", item.targetPath);
|
||||
xhr.upload.onprogress = (e: ProgressEvent) => {
|
||||
if (e.total > 0 && blob.size > 0) {
|
||||
const loaded = doneBytes + e.loaded;
|
||||
item.progress = Math.round((loaded / blob.size) * 100);
|
||||
const seconds = Math.max((Date.now() - startedAt) / 1000, 1);
|
||||
item.speed = loaded / seconds;
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
doneBytes += chunk.size;
|
||||
const httpOk = xhr.status >= 200 && xhr.status < 300;
|
||||
const problem = httpOk
|
||||
? extractBusinessError(parseUploadBody(xhr.responseText))
|
||||
: `HTTP ${xhr.status}`;
|
||||
if (problem) {
|
||||
// 任意一片业务失败即中止(后端合并以物理分片文件为准,重试会整文件重发覆盖)
|
||||
item.status = "failed";
|
||||
item.error = problem;
|
||||
finish(item);
|
||||
return;
|
||||
}
|
||||
if (index < total) {
|
||||
sendChunk(index + 1);
|
||||
return;
|
||||
}
|
||||
// 最后一片的响应才是最终结果(服务端在这片上合并入库)
|
||||
applyResult(item, xhr.status, xhr.responseText);
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
if (!aborted) {
|
||||
item.status = "failed";
|
||||
item.error = "network error";
|
||||
finish(item);
|
||||
}
|
||||
};
|
||||
xhr.send(form);
|
||||
};
|
||||
|
||||
sendChunk(1);
|
||||
return {
|
||||
abort: () => {
|
||||
aborted = true;
|
||||
current?.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** uni 通道(App):先复制成分片名再发(后端按 filename 解析分片);复制失败回退原路径,至少能拿到明确的后端报错 */
|
||||
function uniUpload(item: UploadItem): UploadHandle {
|
||||
let task: UniApp.UploadTask | null = null;
|
||||
let aborted = false;
|
||||
|
||||
const start = (filePath: string): void => {
|
||||
if (aborted) {
|
||||
return;
|
||||
}
|
||||
task = uni.uploadFile({
|
||||
url: `${baseUrl}/DmsFile/UploadDmsFile`,
|
||||
filePath,
|
||||
// 后端约定:form 字段名 = 原始文件名
|
||||
name: item.name,
|
||||
header: {
|
||||
// 口径同主拦截器:归一化 Bearer 前缀(裸 token 会被后端 401),目标目录走 FilePath 头
|
||||
Authorization: bearerAuth(),
|
||||
FilePath: item.targetPath,
|
||||
},
|
||||
success: (res: UniApp.UploadFileSuccessCallbackResult) =>
|
||||
applyResult(item, res.statusCode, res.data),
|
||||
fail: (err) => {
|
||||
// cancel() 主动 abort 也会走 fail:状态已被改写则不覆盖
|
||||
if (item.status !== "cancelled") {
|
||||
item.status = "failed";
|
||||
item.error = err?.errMsg || "upload fail";
|
||||
finish(item);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
task.onProgressUpdate((res) => {
|
||||
item.progress = res.progress;
|
||||
});
|
||||
};
|
||||
|
||||
prepareAppFilePath(item)
|
||||
.then(start)
|
||||
.catch(() => start(item.filePath));
|
||||
|
||||
return {
|
||||
abort: () => {
|
||||
aborted = true;
|
||||
task?.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 任务结束统一收口:盖完成戳 + 清句柄 + 补位下一个 */
|
||||
function finish(item: UploadItem): void {
|
||||
item.finishedAt = Date.now();
|
||||
taskHandles.delete(item.id);
|
||||
item.speed = 0;
|
||||
pump();
|
||||
}
|
||||
|
||||
// ===== 对外动作 =====
|
||||
/** 文件页选好文件后入队;trail 快照当前面包屑链(根→目标目录),之后用户跳走也不影响 */
|
||||
function addFiles(picked: PickedFile[], trail: PathNode[]): void {
|
||||
const targetPath = trail[trail.length - 1]?.value ?? "";
|
||||
picked.forEach((file) => {
|
||||
seq += 1;
|
||||
items.value.push({
|
||||
id: `up-${Date.now().toString(36)}-${seq}`,
|
||||
name: file.name,
|
||||
filePath: file.filePath,
|
||||
size: file.size,
|
||||
raw: file.raw,
|
||||
targetPath,
|
||||
targetTrail: trail.map((node: PathNode) => ({ ...node })),
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
status: "queued",
|
||||
error: "",
|
||||
finishedAt: 0,
|
||||
});
|
||||
});
|
||||
pump();
|
||||
}
|
||||
|
||||
// ===== 跳回目标目录的导航请求 =====
|
||||
// 文件页是 reLaunch 切的 tab 页,页面实例会被销毁重建(目录栈丢失),
|
||||
// 跨页传不了 switchTab 参数,只能把「去哪」暂存在 store 里由文件页 onShow 消费
|
||||
const pendingNav = ref<PathNode[] | null>(null);
|
||||
|
||||
/** 传输页点面包屑时登记目标目录链 */
|
||||
function requestNav(trail: PathNode[]): void {
|
||||
pendingNav.value = trail.map((node: PathNode) => ({ ...node }));
|
||||
}
|
||||
|
||||
/** 文件页取走并清空导航请求(消费式,避免下次回页重复跳) */
|
||||
function takeNav(): PathNode[] | null {
|
||||
const trail = pendingNav.value;
|
||||
pendingNav.value = null;
|
||||
return trail;
|
||||
}
|
||||
|
||||
/** 取消(进行中 abort,排队中直接置为已取消) */
|
||||
function cancel(id: string): void {
|
||||
const item = findItem(id);
|
||||
if (!item || item.status === "success") {
|
||||
return;
|
||||
}
|
||||
const task = taskHandles.get(id);
|
||||
item.status = "cancelled";
|
||||
item.speed = 0;
|
||||
// 排队中取消不经过 finish,终态戳在这里一并盖(否则排序时落底)
|
||||
item.finishedAt = Date.now();
|
||||
if (task) {
|
||||
taskHandles.delete(id);
|
||||
task.abort();
|
||||
}
|
||||
// abort 走的 fail 回调不会替我们补位(状态已是 cancelled,不进 finish),这里自己补
|
||||
pump();
|
||||
}
|
||||
|
||||
/** 失败/取消的任务重新排队 */
|
||||
function retry(id: string): void {
|
||||
const item = findItem(id);
|
||||
if (!item || item.status === "queued" || item.status === "uploading") {
|
||||
return;
|
||||
}
|
||||
item.status = "queued";
|
||||
item.progress = 0;
|
||||
item.error = "";
|
||||
pump();
|
||||
}
|
||||
|
||||
/** 从列表移除(进行中会先取消) */
|
||||
function remove(id: string): void {
|
||||
if (findItem(id)?.status === "uploading") {
|
||||
cancel(id);
|
||||
}
|
||||
const index = items.value.findIndex((item: UploadItem) => item.id === id);
|
||||
if (index >= 0) {
|
||||
items.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空已结束任务(不影响进行中/排队中) */
|
||||
function clearDone(): void {
|
||||
items.value = items.value.filter(isActive);
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
// H5 刷新/关页必断进行中的上传(JS 运行时销毁):有任务时弹浏览器原生确认
|
||||
window.addEventListener("beforeunload", (e: BeforeUnloadEvent) => {
|
||||
if (activeItems.value.length > 0) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
|
||||
return {
|
||||
items,
|
||||
activeItems,
|
||||
doneItems,
|
||||
uploadingCount,
|
||||
addFiles,
|
||||
requestNav,
|
||||
takeNav,
|
||||
cancel,
|
||||
retry,
|
||||
remove,
|
||||
clearDone,
|
||||
};
|
||||
},
|
||||
{
|
||||
// 任务与本地文件路径同生共死,重启后无意义,禁掉全局持久化插件
|
||||
persist: false,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 选文件(H5 / App 共用入口)
|
||||
*
|
||||
* uni.chooseFile 两端语义一致:H5 底层是 <input type="file">(tempFilePath 为 blob: URL,
|
||||
* tempFiles 是 File 对象),App 底层是原生选择器(tempFilePath 为本地路径)。
|
||||
* 差异只体现在拿到的 filePath 形态上——uni.uploadFile 两端都能吃,故上层无需分流。
|
||||
* 用户取消选择按「空结果」处理而不是 reject,调用方不必为取消写 catch。
|
||||
*/
|
||||
|
||||
export interface PickedFile {
|
||||
/** 原始文件名(上传时作为 multipart form 字段名,后端按它落 OrgFileName) */
|
||||
name: string;
|
||||
/** H5 为 blob: URL,App 为本地文件路径 */
|
||||
filePath: string;
|
||||
/** 字节数,个别端拿不到时为 0 */
|
||||
size: number;
|
||||
/** H5 为原生 File 对象(上传通道直接塞 FormData);其他端为 undefined */
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
/** 从路径兜底推导文件名(部分端 tempFiles 缺 name) */
|
||||
function nameFromPath(path: string, index: number): string {
|
||||
const clean = path.split("?")[0];
|
||||
const tail = clean.substring(clean.lastIndexOf("/") + 1);
|
||||
return tail || `file-${index + 1}`;
|
||||
}
|
||||
|
||||
export function chooseFiles(count = 10): Promise<PickedFile[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count,
|
||||
type: "all",
|
||||
success: (res) => {
|
||||
// 声明为 string | string[](各端回传形态不一),统一收敛成数组
|
||||
const raw = res.tempFilePaths as string | string[];
|
||||
const paths: string[] = Array.isArray(raw) ? raw : [raw];
|
||||
const tempFiles = (res.tempFiles || []) as unknown as Array<{
|
||||
name?: string;
|
||||
size?: number;
|
||||
}>;
|
||||
resolve(
|
||||
paths.map((path, index) => ({
|
||||
name: tempFiles[index]?.name || nameFromPath(path, index),
|
||||
filePath: path,
|
||||
size: Number(tempFiles[index]?.size ?? 0) || 0,
|
||||
raw: tempFiles[index],
|
||||
})),
|
||||
);
|
||||
},
|
||||
fail: (err) => {
|
||||
// 取消不算错误:各端 errMsg 文案不一,统一按空结果收敛
|
||||
if (
|
||||
String(err?.errMsg || "")
|
||||
.toLowerCase()
|
||||
.includes("cancel")
|
||||
) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 新建文件夹自定义字形(内联 SVG,不依赖 iconfont 字体文件)。
|
||||
* 字形为用户从 iconfont.cn 挑选的「文件夹 + 加号」图标,
|
||||
* 原双 path fill(#262535/#272636)统一改为按参数注入,与 transferGlyph 同一套路。
|
||||
*/
|
||||
/* 字形分段存(每个元素一条子路径):单行超长拼接会撞 operator-linebreak/Prettier 对撞 */
|
||||
const FOLDER_SEGMENTS = [
|
||||
"M804.536125 253.18949c0 0-293.960055 0.341784-278.447756 0-16.84669 0.478907-25.133424-8.834202-25.133424-8.834202s-11.745509-20.202111-32.803103-51.909254c-22.119787-33.419134-47.526433-28.008914-47.526433-28.008914L203.810361 164.43712c-60.057841 0-60.778248 57.798381-60.778248 57.798381l0 521.971096c0 64.373123 48.587602 56.360635 48.587602 56.360635l269.13567 0c-9.587356-13.662161-17.874089-28.41926-24.791639-43.896766L223.361649 756.670465l0 0.137123c-19.859303 0-35.95284-16.093537-35.95284-35.95284L187.408809 348.276125c0-19.859303 16.093537-35.95284 35.95284-35.95284L773.582135 312.323285c19.860327 0 35.953863 16.093537 35.953863 35.95284l0 82.727143c15.785521 9.758248 30.67872 21.366633 44.23855 34.446533l0-163.227572C853.774549 248.258177 804.536125 253.18949 804.536125 253.18949z",
|
||||
"M865.293907 571.885925c-10.881838-24.965601-25.762757-46.888913-44.647873-65.774029-18.885116-18.885116-40.807405-33.766035-65.773006-44.647873-24.965601-10.881838-51.849902-16.324804-80.654948-16.324804s-55.694463 5.442966-80.659041 16.324804c-24.961508 10.881838-46.883797 25.762757-65.768913 44.647873-18.885116 18.885116-33.766035 40.807405-44.652989 65.774029-10.877745 24.965601-16.319687 51.849902-16.319687 80.653925s5.441942 55.698556 16.319687 80.659041c10.886955 24.961508 25.767873 46.883797 44.652989 65.768913 18.885116 18.883069 40.808428 33.765011 65.768913 44.651966 24.964578 10.876722 51.853995 16.324804 80.659041 16.324804s55.689347-5.448082 80.654948-16.324804c24.965601-10.886955 46.88789-25.768897 65.773006-44.651966 18.885116-18.885116 33.766035-40.808428 44.647873-65.768913 10.882861-24.961508 16.320711-51.855018 16.320711-80.659041S876.176768 596.851527 865.293907 571.885925zM816.323589 670.785399c-5.762237 5.758144-12.799514 8.645914-21.12718 8.645914l-90.25663 0 0 90.252537c0 8.326643-2.87856 15.368012-8.639774 21.126156-5.762237 5.756097-12.799514 8.643867-21.12718 8.643867-8.959046 0-16.319687-2.88777-22.081924-8.643867-5.762237-5.758144-8.640798-12.799514-8.640798-21.126156l0-90.252537-89.300862 0c-8.960069 0-16.320711-2.88777-22.082948-8.645914-5.761214-5.756097-8.639774-12.799514-8.639774-21.12411 0-8.322549 2.87856-15.363919 8.639774-21.126156 5.762237-5.758144 13.122879-8.640798 22.082948-8.640798l89.300862 0 0-90.25663c0-8.322549 2.87856-15.363919 8.640798-21.126156 5.762237-5.757121 13.122879-8.640798 22.081924-8.640798 8.327666 0 15.364943 2.882654 21.12718 8.640798 5.762237 5.762237 8.639774 12.803607 8.639774 21.126156l0 90.25663 90.25663 0c8.327666 0 15.364943 2.882654 21.12718 8.640798 5.762237 5.762237 8.639774 12.803607 8.639774 21.126156C824.964386 657.985886 822.085826 665.029302 816.323589 670.785399z",
|
||||
];
|
||||
|
||||
export function folderGlyph(color: string): string {
|
||||
const d = FOLDER_SEGMENTS.join("");
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="${color}" d="${d}" /></svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 多选开关自定义字形(内联 SVG,不依赖 iconfont 字体文件)。
|
||||
* 字形为用户从 iconfont.cn 挑选的「方框 + 右上箭头(导出/多选)」图标,
|
||||
* 原 fill 色 #999999 改为按参数注入,与 transferGlyph 同一套路。
|
||||
*/
|
||||
/* 字形分段存(每个元素一条子路径):单行超长拼接会撞 operator-linebreak/Prettier 对撞 */
|
||||
const SELECT_SEGMENTS = [
|
||||
"M756.341948 867.328011H111.773992A111.064992 111.064992 0 0 1 0.788 756.342018V111.774062A111.064992 111.064992 0 0 1 111.773992 0.78807H686.629953a39.384997 39.384997 0 0 1 0 78.768995H111.773992a32.294998 32.294998 0 0 0-32.216997 32.216997v644.489956a32.294998 32.294998 0 0 0 32.216997 32.215998h644.489956a32.294998 32.294998 0 0 0 32.215998-32.216998V566.981031a39.384997 39.384997 0 0 1 78.769995 0v189.281987a111.064992 111.064992 0 0 1-110.907993 111.064993z",
|
||||
"M984.614933 1024H293.72998a39.384997 39.384997 0 0 1 0-78.769995h651.499955v-383.999973a39.384997 39.384997 0 0 1 78.769995 0v423.384971A39.384997 39.384997 0 0 1 984.614933 1024z m0-1023.99993a39.541997 39.541997 0 0 0-27.883998 11.499999L455.443969 512.788035a39.384997 39.384997 0 0 0 55.767996 55.767996L1012.499931 67.270065A39.384997 39.384997 0 0 0 984.614933 0.00007z",
|
||||
"M262.773982 278.528051a39.384997 39.384997 0 0 0-27.883998 67.189995l222.759985 222.759985a39.462997 39.462997 0 0 0 55.767996 0.078 39.384997 39.384997 0 0 0 0-55.689996L290.57998 290.02805a38.911997 38.911997 0 0 0-27.805998-11.499999z",
|
||||
];
|
||||
|
||||
export function selectGlyph(color: string): string {
|
||||
const d = SELECT_SEGMENTS.join("");
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="${color}" d="${d}" /></svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 排序自定义字形(内联 SVG,不依赖 iconfont 字体文件)。
|
||||
* 字形为用户从 iconfont.cn 挑选的「三横线 + 右侧下箭头(降序)」图标,
|
||||
* 原 fill 色 #606266 改为按参数注入,与 transferGlyph 同一套路。
|
||||
* 注意:该图标 viewBox 为 1178×1024(非正方形),显示时按此比例给宽高。
|
||||
*/
|
||||
/* 字形分段存(每个元素一条子路径):单行超长拼接会撞 operator-linebreak/Prettier 对撞 */
|
||||
const SORT_SEGMENTS = [
|
||||
"M0 0m56.888668 0l568.886676 0q56.888668 0 56.888668 56.888668l0 0q0 56.888668-56.888668 56.888667l-568.886676 0q-56.888668 0-56.888668-56.888667l0 0q0-56.888668 56.888668-56.888668Z",
|
||||
"M958.801605 0a48.924254 48.924254 0 0 0-47.957147 49.55003v804.40576l-89.542763-92.444085a47.672703 47.672703 0 0 0-67.981958 0 51.541133 51.541133 0 0 0 0 70.200616l171.917554 177.492643a44.145606 44.145606 0 0 0 33.564314 14.847942 47.103817 47.103817 0 0 0 34.1332-14.847942l171.121113-177.492643a49.94825 49.94825 0 0 0 0-70.200616 46.990039 46.990039 0 0 0-67.981958 0l-89.542763 92.444085v-804.40576a48.924254 48.924254 0 0 0-47.957147-49.55003z m0 0",
|
||||
"M0 455.109341m56.888668 0l568.886676 0q56.888668 0 56.888668 56.888668l0 0q0 56.888668-56.888668 56.888668l-568.886676 0q-56.888668 0-56.888668-56.888668l0 0q0-56.888668 56.888668-56.888668Z",
|
||||
"M0 910.218682m56.888668 0l568.886676 0q56.888668 0 56.888668 56.888668l0 0q0 56.888668-56.888668 56.888668l-568.886676 0q-56.888668 0-56.888668-56.888668l0 0q0-56.888668 56.888668-56.888668Z",
|
||||
];
|
||||
|
||||
export function sortGlyph(color: string): string {
|
||||
const d = SORT_SEGMENTS.join("");
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1178 1024"><path fill="${color}" d="${d}" /></svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 传输列表自定义图标(内联 SVG,不依赖 iconfont 字体文件)。
|
||||
* 字形为用户从 iconfont.cn 挑选的「文档 + 上下传输双向箭头」图标,
|
||||
* 原 fill 色 #1A98FF 改为按参数注入:点亮蓝 / 置灰 / 胶囊白三态复用同一份 path。
|
||||
* 颜色嵌进 data URI,无需 CSS mask(兼容性最好)。
|
||||
* 后续若迁 uview-pro 的 customPrefix 字体方案(guide/customIcon):
|
||||
* 在 iconfont.cn 生成项目字体后把 css 挂进 App.vue,调用点把 <image> 换成
|
||||
* <u-icon custom-prefix="项目前缀" name="图标名" /> 即可,其余不动。
|
||||
*/
|
||||
/* 字形分段存(每个元素一条子路径):单行超长拼接会撞 operator-linebreak/Prettier 对撞 */
|
||||
const TRANSFER_SEGMENTS = [
|
||||
"M860.761163 796.410753C926.279311 796.410753 975.137867 747.425739 975.137867 684.73061L975.137867 404.925894C975.137867 392.046095 969.617879 379.726764 960.422881 370.597343L645.152481 52.269778C644.403016 51.524745 641.008916 47.980376 640.01255 46.973897 630.422958 37.286967 622.698045 32.081698 610.901381 32.081698L165.692649 32.081698C99.861393 32.081698 46.921253 89.641221 46.921253 154.208962L46.921253 684.73061C46.921253 748.713914 100.717609 796.410753 165.692649 796.410753L307.040014 796.410753C318.350803 796.410753 327.520014 787.241546 327.520014 775.930753 327.520014 764.619964 318.350803 755.450753 307.040014 755.450753L165.692649 755.450753C122.314887 755.450753 87.881253 724.921262 87.881253 684.73061L87.881253 154.208962C87.881253 111.533131 123.283595 73.041698 165.692649 73.041698L610.901381 73.041698C607.862907 73.041698 608.822874 73.688551 610.903577 75.790372 611.528118 76.421254 615.090516 80.141373 616.172863 81.215816L931.442023 399.54215C933.274368 401.362446 934.177867 403.378846 934.177867 404.925894L934.177867 684.73061C934.177867 724.827824 903.633992 755.450753 860.761163 755.450753L714.501044 755.450753C703.190253 755.450753 694.021044 764.619964 694.021044 775.930753 694.021044 787.241546 703.190253 796.410753 714.501044 796.410753L860.761163 796.410753Z",
|
||||
"M553.757495 32.792471C553.757495 39.492335 552.558862 289.568912 552.558858 326.785341 552.558858 395.012162 607.313412 450.559971 675.148493 450.559971L962.559997 450.559971 962.559997 409.599971 675.148493 409.599971C630.069096 409.599971 593.518858 372.522599 593.518858 326.785341 593.518858 326.785341 594.717495 32.790614 594.717495 32.790614L553.757495 32.792471Z",
|
||||
"M167.529513 171.736852C158.48088 171.736852 151.145513 179.072218 151.145513 188.120852 151.145513 197.169485 158.48088 204.504852 167.529513 204.504852L496.022479 204.504852C505.071112 204.504852 512.406479 197.169486 512.406479 188.120852 512.406479 179.072219 505.071112 171.736852 496.022479 171.736852L167.529513 171.736852Z",
|
||||
"M167.529513 335.576852C158.48088 335.576852 151.145513 342.912218 151.145513 351.960852 151.145513 361.009485 158.48088 368.344852 167.529513 368.344852L496.022479 368.344852C505.071112 368.344852 512.406479 361.009486 512.406479 351.960852 512.406479 342.912219 505.071112 335.576852 496.022479 335.576852L167.529513 335.576852Z",
|
||||
"M645.417341 1009.171685 751.955069 902.263013C759.694973 894.151397 759.694973 881.619173 751.955069 873.519075 744.215165 865.396707 731.311229 865.396707 723.199613 873.519075L646.530941 950.559461 646.530941 509.210083C646.530941 499.624677 638.419325 491.519971 628.462973 491.519971 618.506621 491.519971 610.766717 499.625443 610.766717 509.210083L610.766717 950.559461 534.086525 873.519075C526.346621 865.396707 513.442685 865.396707 505.337213 873.519075 497.591165 881.619173 497.591165 894.151397 505.337213 902.263013L611.868795 1009.171685C614.822523 1012.119269 618.141053 1013.593059 621.825147 1014.335717 623.670653 1015.437795 626.249597 1015.809507 628.462973 1015.809507 630.674045 1015.809507 633.623933 1015.437795 635.466365 1014.335717 639.151229 1013.593059 642.470525 1012.119269 645.417341 1009.171685Z",
|
||||
"M399.657341 498.157797 506.195067 605.066469C513.934971 613.178085 513.934971 625.710309 506.195067 633.810403 498.455163 641.932771 485.551227 641.932771 477.439611 633.810403L400.770941 556.770021 400.770941 998.119395C400.770941 1007.704805 392.659325 1015.809507 382.702973 1015.809507 372.746621 1015.809507 365.006717 1007.704035 365.006717 998.119395L365.006717 556.770021 288.326525 633.810403C280.586621 641.932771 267.682685 641.932771 259.577213 633.810403 251.831165 625.710309 251.831165 613.178085 259.577213 605.066469L366.108795 498.157797C369.062523 495.210213 372.381053 493.736419 376.065147 492.993765 377.910653 491.891683 380.489597 491.519971 382.702973 491.519971 384.914043 491.519971 387.863933 491.891683 389.706363 492.993765 393.391227 493.736419 396.710523 495.210213 399.657341 498.157797Z",
|
||||
];
|
||||
|
||||
/** 点亮态主色:沿用用户在 iconfont 选定的 #1A98FF */
|
||||
export const TRANSFER_ACTIVE_COLOR = "#1a98ff";
|
||||
|
||||
export function transferGlyph(color: string): string {
|
||||
const d = TRANSFER_SEGMENTS.join("");
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="${color}" d="${d}" /></svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
Reference in new issue
Block a user