Merge remote-tracking branch 'origin/master'
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
This commit is contained in:
commit
fb3f8ae277
73 files changed
+1559
-338
No files matched your search
+4
-1
@@ -52,4 +52,7 @@ coverage/
|
||||
.video_agent/
|
||||
.mimosa/
|
||||
.zcode/
|
||||
.zcodeignore
|
||||
.zcodeignore
|
||||
|
||||
# Local file-access credentials
|
||||
.env.*.local
|
||||
@@ -1,4 +1,7 @@
|
||||
import request from "/@/utils/request"
|
||||
import { submitExportTask } from "/@/api/asyncTask"
|
||||
|
||||
export const submitDeptExport = (query: Record<string, unknown>) => submitExportTask("/admin/dept/export-async", query)
|
||||
|
||||
export const deptTree = (params?: Object) => {
|
||||
return request({
|
||||
|
||||
+53
-53
@@ -1,138 +1,138 @@
|
||||
import request from '/@/utils/request';
|
||||
import request from "/@/utils/request"
|
||||
|
||||
export const getDicts = (type: String) => {
|
||||
return request({
|
||||
url: `/admin/dict/type/${type}`,
|
||||
method: 'get',
|
||||
});
|
||||
};
|
||||
method: "get",
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchList(query: any) {
|
||||
return request({
|
||||
url: '/admin/dict/list',
|
||||
method: 'get',
|
||||
url: "/admin/dict/list",
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchItemList(query: any) {
|
||||
return request({
|
||||
url: '/admin/dict/item/page',
|
||||
method: 'get',
|
||||
url: "/admin/dict/item/page",
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function addItemObj(obj: any) {
|
||||
return request({
|
||||
url: '/admin/dict/item',
|
||||
method: 'post',
|
||||
url: "/admin/dict/item",
|
||||
method: "post",
|
||||
data: obj,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function getItemObj(id: string) {
|
||||
return request({
|
||||
url: '/admin/dict/item/details/' + id,
|
||||
method: 'get',
|
||||
});
|
||||
url: "/admin/dict/item/details/" + id,
|
||||
method: "get",
|
||||
})
|
||||
}
|
||||
|
||||
export function getItemDetails(obj: object) {
|
||||
return request({
|
||||
url: '/admin/dict/item/details',
|
||||
method: 'get',
|
||||
url: "/admin/dict/item/details",
|
||||
method: "get",
|
||||
params: obj,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function delItemObj(id: string) {
|
||||
return request({
|
||||
url: '/admin/dict/item/' + id,
|
||||
method: 'delete',
|
||||
});
|
||||
url: "/admin/dict/item/" + id,
|
||||
method: "delete",
|
||||
})
|
||||
}
|
||||
|
||||
export function putItemObj(obj: any) {
|
||||
return request({
|
||||
url: '/admin/dict/item',
|
||||
method: 'put',
|
||||
url: "/admin/dict/item",
|
||||
method: "put",
|
||||
data: obj,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function addObj(obj: any) {
|
||||
return request({
|
||||
url: '/admin/dict',
|
||||
method: 'post',
|
||||
url: "/admin/dict",
|
||||
method: "post",
|
||||
data: obj,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function getObj(id: string) {
|
||||
return request({
|
||||
url: '/admin/dict/details/' + id,
|
||||
method: 'get',
|
||||
});
|
||||
url: "/admin/dict/details/" + id,
|
||||
method: "get",
|
||||
})
|
||||
}
|
||||
|
||||
export function getObjDetails(obj: object) {
|
||||
return request({
|
||||
url: '/admin/dict/details',
|
||||
method: 'get',
|
||||
url: "/admin/dict/details",
|
||||
method: "get",
|
||||
params: obj,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function delObj(ids: Object) {
|
||||
return request({
|
||||
url: '/admin/dict',
|
||||
method: 'delete',
|
||||
url: "/admin/dict",
|
||||
method: "delete",
|
||||
data: ids,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function putObj(obj: any) {
|
||||
return request({
|
||||
url: '/admin/dict',
|
||||
method: 'put',
|
||||
url: "/admin/dict",
|
||||
method: "put",
|
||||
data: obj,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function refreshCache() {
|
||||
return request({
|
||||
url: '/admin/dict/sync',
|
||||
method: 'put',
|
||||
});
|
||||
url: "/admin/dict/sync",
|
||||
method: "put",
|
||||
})
|
||||
}
|
||||
|
||||
export function validateDictType(rule: any, value: any, callback: any, isEdit: boolean) {
|
||||
if (isEdit) {
|
||||
return callback();
|
||||
return callback()
|
||||
}
|
||||
|
||||
getObjDetails({ dictType: value }).then((response) => {
|
||||
const result = response.data;
|
||||
const result = response.data
|
||||
if (result !== null) {
|
||||
callback(new Error('字典已经存在'));
|
||||
callback(new Error("字典已经存在"))
|
||||
} else {
|
||||
callback();
|
||||
callback()
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function validateDictItemLabel(rule: any, value: any, callback: any, type: string, isEdit: boolean) {
|
||||
if (isEdit) {
|
||||
return callback();
|
||||
return callback()
|
||||
}
|
||||
|
||||
getItemDetails({ dictType: type, label: value }).then((response) => {
|
||||
const result = response.data;
|
||||
const result = response.data
|
||||
if (result !== null) {
|
||||
callback(new Error('标签已经存在'));
|
||||
callback(new Error("标签已经存在"))
|
||||
} else {
|
||||
callback();
|
||||
callback()
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import request from '/@/utils/request';
|
||||
import { submitExportTask } from '/@/api/asyncTask';
|
||||
|
||||
export const submitLogExport = (query: Record<string, unknown>) => submitExportTask('/admin/log/export-async', query);
|
||||
|
||||
export const pageList = (params?: Object) => {
|
||||
return request({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col border border-br" :style="styles">
|
||||
<div ref="editorContainer" class="flex flex-col border border-br" :style="styles">
|
||||
<Toolbar class="border-b border-br" :editor="editorRef" :mode="mode" />
|
||||
<Editor
|
||||
class="flex-1 overflow-y-auto"
|
||||
@@ -18,8 +18,11 @@ import { reactive, shallowRef, watch, onBeforeUnmount, CSSProperties } from 'vue
|
||||
// @ts-ignore
|
||||
import { IDomEditor } from '@wangeditor/editor';
|
||||
import { Toolbar, Editor } from '@wangeditor/editor-for-vue';
|
||||
import { stripFileHtml, resolveFileUrl } from '/@/utils/file-access';
|
||||
import { bindFileAccessDom } from '/@/utils/file-access-dom';
|
||||
import { Session } from '/@/utils/storage';
|
||||
import other from '/@/utils/other';
|
||||
import request from '/@/utils/request';
|
||||
const { proxy } = getCurrentInstance();
|
||||
|
||||
// 定义父组件传过来的值
|
||||
@@ -73,15 +76,28 @@ const headers = computed(() => {
|
||||
|
||||
// 定义上传需要的字段信息
|
||||
const uploadAttr = reactive({
|
||||
async customUpload(file: File, insertFn: (url: string) => void) {
|
||||
const data = new FormData();
|
||||
data.append('file', file);
|
||||
try {
|
||||
const response = await request<any, { code: number; data?: { url: string }; msg?: string }>({ url: props.uploadFileUrl, method: 'post', data });
|
||||
if (response.code !== 0 || !response.data?.url) throw new Error(response.msg || '文件上传失败');
|
||||
insertFn(resolveFileUrl(response.data.url));
|
||||
} catch (error: any) {
|
||||
throw new Error(error?.msg || error?.message || '文件上传失败');
|
||||
}
|
||||
},
|
||||
fieldName: 'file',
|
||||
server: proxy.baseURL + props.uploadFileUrl,
|
||||
headers: headers,
|
||||
customInsert(res, insertFn) {
|
||||
insertFn(proxy.baseURL + res.data.url);
|
||||
insertFn(resolveFileUrl(res.data.url));
|
||||
},
|
||||
});
|
||||
|
||||
const editorRef = shallowRef();
|
||||
const editorContainer = shallowRef<HTMLElement>();
|
||||
let stopResourceRenewal: (() => void) | undefined;
|
||||
const state = reactive({
|
||||
editorConfig: {
|
||||
placeholder: props.placeholder,
|
||||
@@ -90,7 +106,7 @@ const state = reactive({
|
||||
uploadVideo: uploadAttr,
|
||||
},
|
||||
},
|
||||
editorVal: props.getHtml,
|
||||
editorVal: stripFileHtml(props.getHtml || ""),
|
||||
});
|
||||
|
||||
const styles = computed<CSSProperties>(() => ({
|
||||
@@ -102,16 +118,21 @@ const styles = computed<CSSProperties>(() => ({
|
||||
// 编辑器回调函数
|
||||
const handleCreated = (editor: IDomEditor) => {
|
||||
editorRef.value = editor;
|
||||
nextTick(() => {
|
||||
stopResourceRenewal?.();
|
||||
if (editorContainer.value) stopResourceRenewal = bindFileAccessDom(editorContainer.value);
|
||||
});
|
||||
};
|
||||
|
||||
// 编辑器内容改变时
|
||||
const handleChange = (editor: IDomEditor) => {
|
||||
emit('update:getHtml', editor.getHtml());
|
||||
emit('update:getHtml', stripFileHtml(editor.getHtml()));
|
||||
emit('update:getText', editor.getText());
|
||||
};
|
||||
|
||||
// 页面销毁时
|
||||
onBeforeUnmount(() => {
|
||||
stopResourceRenewal?.();
|
||||
const editor = editorRef.value;
|
||||
if (editor == null) return;
|
||||
emit('update:getHtml', '');
|
||||
@@ -132,7 +153,7 @@ onMounted(() =>{
|
||||
watch(
|
||||
() => props.getHtml,
|
||||
(val) => {
|
||||
state.editorVal = val;
|
||||
if (stripFileHtml(state.editorVal || "") !== stripFileHtml(val || "")) state.editorVal = stripFileHtml(val || "");
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watch } from 'vue'
|
||||
import { ElImage } from 'element-plus'
|
||||
import { accessFileUrl, fileUrls, originalFileUrl, resolveFileUrl, fileAccessRevision, retryFileAccess } from '/@/utils/file-access'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
const props = withDefaults(defineProps<{ src?: string; previewSrcList?: string[] }>(), {
|
||||
src: '', previewSrcList: () => [],
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
load: [event: Event]
|
||||
error: [event: Event]
|
||||
show: []
|
||||
switch: [index: number]
|
||||
close: []
|
||||
}>()
|
||||
const displaySrc = shallowRef('')
|
||||
const previewSources = shallowRef<string[]>([])
|
||||
let generation = 0
|
||||
let loaded = false
|
||||
let failed = false
|
||||
let pending = false
|
||||
let lastError: Event
|
||||
|
||||
const identity = (source: string) => resolveFileUrl(originalFileUrl(source))
|
||||
const refreshPreview = () => { previewSources.value = fileUrls(props.previewSrcList) }
|
||||
|
||||
// An already decoded image remains visible after its request signature expires.
|
||||
// Only a changed business reference or a failed request needs a new thumbnail request.
|
||||
watch(() => identity(props.src), () => {
|
||||
generation++
|
||||
loaded = failed = pending = false
|
||||
displaySrc.value = accessFileUrl(props.src)
|
||||
}, { immediate: true })
|
||||
watch(() => JSON.stringify(props.previewSrcList.map(identity)), refreshPreview, { immediate: true })
|
||||
|
||||
function onLoad(event: Event) { loaded = true; failed = false; emit('load', event) }
|
||||
async function onError(event: Event) {
|
||||
failed = true
|
||||
lastError = event
|
||||
if (pending) return
|
||||
const current = generation
|
||||
pending = true
|
||||
try {
|
||||
const retried = await retryFileAccess(props.src)
|
||||
if (current !== generation) return
|
||||
if (retried) {
|
||||
failed = false
|
||||
displaySrc.value = accessFileUrl(props.src)
|
||||
} else emit('error', event)
|
||||
} finally { if (current === generation) pending = false }
|
||||
}
|
||||
watch(fileAccessRevision, () => {
|
||||
if (loaded || pending) return
|
||||
const renewed = accessFileUrl(props.src)
|
||||
if (renewed !== displaySrc.value) {
|
||||
failed = false
|
||||
displaySrc.value = renewed
|
||||
} else if (failed) void onError(lastError)
|
||||
})
|
||||
function onShow() { refreshPreview(); emit('show') }
|
||||
function onSwitch(index: number) { refreshPreview(); emit('switch', index) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElImage v-bind="$attrs" :src="displaySrc" :preview-src-list="previewSources"
|
||||
@load="onLoad" @error="onError" @show="onShow" @switch="onSwitch" @close="emit('close')">
|
||||
<template v-for="(_, name) in $slots" #[name]="scope">
|
||||
<slot :name="name" v-bind="scope || {}" />
|
||||
</template>
|
||||
</ElImage>
|
||||
</template>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative flex items-center justify-center file-item" :style="{ height: fileSize, width: fileSize }">
|
||||
<el-image class="image" v-if="type === 'image'" fit="contain" :src="uri"></el-image>
|
||||
<video class="video" v-else-if="type === 'video'" :src="uri"></video>
|
||||
<file-image class="image" v-if="type === 'image'" fit="contain" :src="$fileUrl(uri)" @error="$fileRetry(uri)"></file-image>
|
||||
<video class="video" v-else-if="type === 'video'" :src="$fileUrl(uri)"></video>
|
||||
<div
|
||||
v-if="type == 'video'"
|
||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] rounded-full w-5 h-5 flex justify-center items-center bg-[rgba(0,0,0,0.3)]"
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="type === 'file'" class="flex items-center justify-center">
|
||||
<img class="w-16" :src="getFileImage(uri)" />
|
||||
<img class="w-16" :src="$fileUrl(getFileImage(uri))" @error="$fileRetry(getFileImage(uri))" />
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="名称" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link @click.stop="handlePreview(getFileUri(row))" :underline="false">
|
||||
<el-link @click.stop="handlePreview(row)" :underline="false">
|
||||
{{ row.original }}
|
||||
</el-link>
|
||||
</template>
|
||||
@@ -165,7 +165,7 @@
|
||||
</popover-input>
|
||||
</div>
|
||||
<div class="inline-block">
|
||||
<el-button type="primary" link @click.stop="handlePreview(getFileUri(row))"> 查看</el-button>
|
||||
<el-button type="primary" link @click.stop="handlePreview(row)"> 查看</el-button>
|
||||
</div>
|
||||
<div class="inline-block">
|
||||
<el-button type="primary" link @click.stop="batchFileDelete([row.id])"> 删除</el-button>
|
||||
@@ -330,7 +330,7 @@ const handlePreview = (item: { fileName: string }) => {
|
||||
* @param {any} item - 文件项对象
|
||||
*/
|
||||
const handleDownFile = (item: any) => {
|
||||
other.downBlobFile(`/admin/sys-file/oss/file?fileName=${item.fileName}`, {}, item.original);
|
||||
other.downBlobFile(`/admin/sys-file/oss/file?fileName=${encodeURIComponent(item.fileName)}`, {}, item.original);
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -371,7 +371,7 @@ watch(
|
||||
);
|
||||
|
||||
const getFileUri = (item: any) => {
|
||||
return `${proxy.baseURL}/admin/sys-file/oss/file?fileName=${item.fileName}`;
|
||||
return `${proxy.baseURL}/admin/sys-file/oss/file?fileName=${encodeURIComponent(item.fileName)}`;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-show="modelValue">
|
||||
<div v-if="type == 'image'">
|
||||
<el-image-viewer v-if="previewLists.length" :url-list="previewLists" hide-on-click-modal @close="handleClose"/>
|
||||
<el-image-viewer v-if="previewLists.length" :url-list="$fileUrls(previewLists)" hide-on-click-modal @close="handleClose"/>
|
||||
</div>
|
||||
<div v-if="type == 'video'">
|
||||
<el-dialog v-model="visible" width="740px" :title="$t('material.preview')" :before-close="handleClose">
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {Base64} from 'js-base64';
|
||||
import { accessFileUrl, resolveFileUrl } from '/@/utils/file-access';
|
||||
import {validateNull} from "/@/utils/validate";
|
||||
|
||||
const VideoPlayer = defineAsyncComponent(() => import('/@/components/VideoPlayer/index.vue'));
|
||||
@@ -88,6 +89,10 @@ const src = computed(() => {
|
||||
if (validateNull(kkServerURL)) {
|
||||
return undefined;
|
||||
}
|
||||
return `${kkServerURL}?url=` + encodeURIComponent(Base64.encode(`${localURL}${props.url}&fullfilename=${props.fileName}`));
|
||||
const signed = accessFileUrl(props.url);
|
||||
const resolved = /^https?:\/\//i.test(signed) ? signed : `${localURL?.replace(/\/$/, '') || location.origin}${resolveFileUrl(signed)}`;
|
||||
const source = new URL(resolved);
|
||||
source.searchParams.set('fullfilename', props.fileName);
|
||||
return `${kkServerURL}?url=` + encodeURIComponent(Base64.encode(source.toString()));
|
||||
});
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div :class="mode == 'square' ? 'chatface' : 'brround avatar cover-image'" :style="transform" style="overflow: hidden; width: 40px; height: 40px">
|
||||
<img v-if="faceUrl && !num" :src="faceUrl" class="w-100 h-100" />
|
||||
<img v-if="faceUrl && !num" :src="$fileUrl(faceUrl)" class="w-100 h-100" @error="$fileRetry(faceUrl)" />
|
||||
<div v-else :style="styles" class="w-100 h-100 d-flex ai-center jc-center">{{ text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- 图片预览 -->
|
||||
<script lang="ts" setup name="PreviewImage">
|
||||
import { resolveFileUrl } from '/@/utils/file-access'
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
import { downBlobFile } from "/@/utils/other"
|
||||
|
||||
@@ -55,7 +56,7 @@ const realHeight = computed(() => (typeof props.height == "string" ? props.heigh
|
||||
|
||||
function dispatchUrl(url?: string) {
|
||||
if (!url) return ""
|
||||
return url.includes("http") ? url : import.meta.env.VITE_IMAGE_BASE_PATH + url
|
||||
return resolveFileUrl(url)
|
||||
}
|
||||
|
||||
// 图片下载
|
||||
@@ -67,21 +68,21 @@ const download = (src: string) => {
|
||||
|
||||
<template>
|
||||
<div v-if="props.src" class="flex flex-col items-center">
|
||||
<el-image
|
||||
<file-image
|
||||
hide-on-click-modal
|
||||
preview-teleported
|
||||
:src="`${realSrc}`"
|
||||
:src="$fileUrl(`${realSrc}`)"
|
||||
fit="cover"
|
||||
:style="`width:${realWidth};height:${realHeight};`"
|
||||
:preview-src-list="realSrcList"
|
||||
:preview-src-list="$fileUrls(realSrcList)"
|
||||
loading="lazy"
|
||||
>
|
||||
@error="$fileRetry(`${realSrc}`)">
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<i class="el-icon-picture-outline"></i>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</file-image>
|
||||
<el-button v-if="props.download" size="small" type="primary" link @click="download(props.src)">下载</el-button>
|
||||
</div>
|
||||
<div v-else :style="`width:${realWidth};height:${realHeight};`" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<component :is="getIconName" />
|
||||
</i>
|
||||
<div v-else-if="isShowIconImg" :style="setIconImgOutStyle">
|
||||
<img :src="getIconName" :style="setIconSvgInsStyle" />
|
||||
<img :src="$fileUrl(getIconName)" :style="setIconSvgInsStyle" @error="$fileRetry(getIconName)" />
|
||||
</div>
|
||||
<svg v-else-if="isShowLocalSvg" class="svg-icon icon" :style="setIconImgOutStyle">
|
||||
<use :href="`#${getIconName}`" :fill="color"/>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
>
|
||||
<template v-if="imageUrl">
|
||||
<!-- 如果返回的是OSS 地址则不需要增加 baseURL -->
|
||||
<img :src="imageUrl.includes('http') ? imageUrl : baseURL + imageUrl" class="upload-image" />
|
||||
<img :src="$fileUrl(imageUrl)" class="upload-image" @error="$fileRetry(imageUrl)" />
|
||||
<div class="upload-handle" @click.stop>
|
||||
<div class="handle-icon" @click="editImg" v-if="!self_disabled">
|
||||
<el-icon :size="props.iconSize"><Edit /></el-icon>
|
||||
@@ -48,7 +48,7 @@
|
||||
:teleported="true"
|
||||
v-if="imgViewVisible"
|
||||
@close="imgViewVisible = false"
|
||||
:url-list="[imageUrl.includes('http') ? imageUrl : baseURL + imageUrl]"
|
||||
:url-list="$fileUrls([imageUrl])"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="upload-box">
|
||||
<div ref="imageContainer" class="upload-box">
|
||||
<el-upload
|
||||
v-model:file-list="fileList"
|
||||
list-type="picture-card"
|
||||
@@ -19,12 +19,14 @@
|
||||
</el-upload>
|
||||
|
||||
<el-dialog v-model="dialogVisible">
|
||||
<img class="m-auto" w-full :src="dialogImageUrl" alt="Preview Image" />
|
||||
<img class="m-auto" w-full :src="$fileUrl(dialogImageUrl)" alt="Preview Image" @error="$fileRetry(dialogImageUrl)" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fileUrl, originalFileUrl, resolveFileUrl } from '/@/utils/file-access'
|
||||
import { bindFileAccessDom } from '/@/utils/file-access-dom'
|
||||
import { ref, watch } from "vue"
|
||||
import { Plus } from "@element-plus/icons-vue"
|
||||
import { ElUpload, ElDialog, UploadRequestOptions, ElNotification, UploadFile, UploadProps, formContextKey } from "element-plus"
|
||||
@@ -47,9 +49,13 @@ const props = withDefaults(defineProps<UploadFileProps>(), {
|
||||
borderRadius: "8px",
|
||||
})
|
||||
|
||||
const imageContainer = ref<HTMLElement>()
|
||||
let stopResourceRenewal: (() => void) | undefined
|
||||
onMounted(() => { if (imageContainer.value) stopResourceRenewal = bindFileAccessDom(imageContainer.value) })
|
||||
onBeforeUnmount(() => stopResourceRenewal?.())
|
||||
|
||||
const imageUrls = defineModel("modelValue", { default: "" })
|
||||
|
||||
const imageBaseUrl = import.meta.env.VITE_IMAGE_BASE_PATH
|
||||
const dialogVisible = ref(false)
|
||||
const fileList = ref<any>([])
|
||||
const dialogImageUrl = ref("")
|
||||
@@ -105,19 +111,18 @@ const beforeUpload: UploadProps["beforeUpload"] = (rawFile) => {
|
||||
* @description 图片上传成功
|
||||
* */
|
||||
const uploadSuccess = (response: any, uploadFile: UploadFile, uploadFiles: any) => {
|
||||
console.log("success", uploadFiles)
|
||||
|
||||
|
||||
const urls = uploadFiles
|
||||
.map((item: any) => {
|
||||
// 处理新上传和已有的图片URL
|
||||
return item.response ? item.response.data.url : item.fileUrl
|
||||
return originalFileUrl(item.response ? item.response.data.url : item.fileUrl)
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(",")
|
||||
imageUrls.value = urls
|
||||
|
||||
console.log("fileList.value", fileList.value)
|
||||
console.log("urls", urls)
|
||||
|
||||
|
||||
ElNotification({
|
||||
title: "温馨提示",
|
||||
@@ -136,7 +141,7 @@ const handlePictureCardPreview = (uploadFile: any) => {
|
||||
const handleRemove = (uploadFile: any, uploadFiles: any[]) => {
|
||||
const urls = uploadFiles
|
||||
.map((item) => {
|
||||
return item.response ? item.response.data.url : item.fileUrl
|
||||
return originalFileUrl(item.response ? item.response.data.url : item.fileUrl)
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(",")
|
||||
@@ -171,16 +176,15 @@ watch(
|
||||
fileList.value = newValue
|
||||
.split(",")
|
||||
.map((item: string) => {
|
||||
const url = item.startsWith("http") ? item : imageBaseUrl + item
|
||||
const url = resolveFileUrl(item)
|
||||
return {
|
||||
name: item.split("/").pop() || "",
|
||||
url: url,
|
||||
url: fileUrl(url),
|
||||
fileUrl: item,
|
||||
}
|
||||
})
|
||||
.filter((item) => item.url)
|
||||
|
||||
console.log("fileList.value", fileList.value)
|
||||
} else {
|
||||
fileList.value = newValue
|
||||
.split("?")
|
||||
@@ -189,16 +193,15 @@ watch(
|
||||
item.endsWith(".jpg") || item.endsWith(".png") || item.endsWith(".gif") || item.endsWith(".jpeg") || item.endsWith(".webp"),
|
||||
)
|
||||
.map((item: string) => {
|
||||
const url = item.startsWith("http") ? item : imageBaseUrl + item
|
||||
const url = resolveFileUrl(item)
|
||||
return {
|
||||
name: item.split("/").pop() || "",
|
||||
url: url,
|
||||
url: fileUrl(url),
|
||||
fileUrl: item,
|
||||
}
|
||||
})
|
||||
.filter((item) => item.url)
|
||||
|
||||
console.log("fileList.value", fileList.value)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<template>
|
||||
<div class="w-full upload-file">
|
||||
<el-upload ref="fileUpload" v-if="props.type === 'default'"
|
||||
:http-request="handleHttpUpload"
|
||||
:action="baseURL + other.adaptationUrl(props.uploadFileUrl)" :before-upload="handleBeforeUpload"
|
||||
:file-list="fileList" :headers="headers" :limit="limit" :on-error="handleUploadError"
|
||||
:on-remove="handleRemove" :on-preview="handlePreview" :on-exceed="handleExceed" :data="formData"
|
||||
@@ -24,6 +25,7 @@
|
||||
</template>
|
||||
</el-upload>
|
||||
<el-upload ref="fileUpload" v-if="props.type === 'simple'"
|
||||
:http-request="handleHttpUpload"
|
||||
:action="baseURL + other.adaptationUrl(props.uploadFileUrl)" :before-upload="handleBeforeUpload"
|
||||
:file-list="fileList" :headers="headers" :limit="limit" :auto-upload="autoUpload"
|
||||
:on-error="handleUploadError" :on-remove="handleRemove" :on-exceed="handleExceed" :data="formData"
|
||||
@@ -38,6 +40,8 @@ import { useMessage } from '/@/hooks/message';
|
||||
import { Session } from '/@/utils/storage';
|
||||
import other from '/@/utils/other';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import request from '/@/utils/request';
|
||||
import type { UploadRequestOptions } from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: [String, Array],
|
||||
@@ -53,7 +57,7 @@ const props = defineProps({
|
||||
},
|
||||
fileType: {
|
||||
type: Array,
|
||||
default: () => ['png', 'jpg', 'jpeg', 'doc', 'xls', 'ppt', 'txt', 'pdf', 'docx', 'xlsx', 'pptx', 'apk'],
|
||||
default: () => ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'svg', 'pdf', 'apk', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'zip', 'mp4', 'mov', 'webm', 'avi', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'amr', 'flac'],
|
||||
},
|
||||
// 是否显示提示
|
||||
isShowTip: {
|
||||
@@ -93,6 +97,16 @@ const uploadList = ref<{ name: string; url: string; fileUrl: string; fileSize: n
|
||||
const fileUpload = ref();
|
||||
const { t } = useI18n();
|
||||
|
||||
// Axios applies the gateway prefix, architecture adaptation and fresh signature once.
|
||||
const handleHttpUpload = (options: UploadRequestOptions) => {
|
||||
const data = new FormData();
|
||||
for (const [name, value] of Object.entries(options.data || {})) {
|
||||
if (value != null) data.append(name, value instanceof Blob ? value : String(value));
|
||||
}
|
||||
data.append(options.filename || 'file', options.file);
|
||||
return request({ url: props.uploadFileUrl, method: 'post', data, timeout: 0 });
|
||||
};
|
||||
|
||||
// 请求头处理
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
@@ -111,8 +125,8 @@ const handleBeforeUpload = (file: File) => {
|
||||
// 校检文件类型
|
||||
if (props.fileType.length) {
|
||||
const fileName = file.name.split('.');
|
||||
const fileExt = fileName[fileName.length - 1];
|
||||
const isTypeOk = props.fileType.indexOf(fileExt) >= 0;
|
||||
const fileExt = fileName[fileName.length - 1].toLowerCase();
|
||||
const isTypeOk = props.fileType.some(value => String(value).toLowerCase() === fileExt);
|
||||
if (!isTypeOk) {
|
||||
useMessage().error(`${t('excel.typeErrorText')} ${props.fileType.join('/')}!`);
|
||||
return false;
|
||||
@@ -193,8 +207,10 @@ const listToString = (list: { url: string }[], separator = ','): string => {
|
||||
return strs !== '' ? strs.substr(0, strs.length - 1) : '';
|
||||
};
|
||||
|
||||
const handleUploadError = () => {
|
||||
useMessage().error('上传文件失败');
|
||||
const handleUploadError = (error: any) => {
|
||||
number.value = Math.max(0, number.value - 1);
|
||||
useMessage().error(error?.msg || error?.message || '上传文件失败');
|
||||
uploadedSuccessfully();
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</el-upload>
|
||||
|
||||
<el-dialog v-model="dialogVisible">
|
||||
<img class="m-auto" w-full :src="dialogImageUrl" alt="Preview Image" />
|
||||
<img class="m-auto" w-full :src="$fileUrl(dialogImageUrl)" alt="Preview Image" @error="$fileRetry(dialogImageUrl)" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center gap-3 text-left">
|
||||
<el-avatar :size="props.avatarSize" :src="props.user.avatar || undefined" :alt="displayName" class="user-info-cell__avatar shrink-0">
|
||||
<el-avatar :size="props.avatarSize" :src="$fileUrl(props.user.avatar) || undefined" @error="$fileRetry(props.user.avatar)" :alt="displayName" class="user-info-cell__avatar shrink-0">
|
||||
{{ avatarFallback }}
|
||||
</el-avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
<i class="iconfont icon-refresh"></i>
|
||||
</div>
|
||||
<img
|
||||
:src="'data:image/png;base64,' + pointBackImgBase"
|
||||
:src="$fileUrl('data:image/png;base64,' + pointBackImgBase)"
|
||||
ref="canvas"
|
||||
alt=""
|
||||
style="width: 100%; height: 100%; display: block"
|
||||
@click="bindingClick ? canvasClick($event) : undefined"
|
||||
/>
|
||||
@error="$fileRetry('data:image/png;base64,' + pointBackImgBase)" />
|
||||
|
||||
<div
|
||||
v-for="(tempPoint, index) in tempPoints"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div style="position: relative">
|
||||
<div v-if="type === '2'" :style="{ height: parseInt(setSize.imgHeight) + vSpace + 'px' }" class="verify-img-out">
|
||||
<div :style="{ width: setSize.imgWidth, height: setSize.imgHeight }" class="verify-img-panel modern-shadow">
|
||||
<img :src="'data:image/png;base64,' + backImgBase" alt="" style="width: 100%; height: 100%; display: block" />
|
||||
<img :src="$fileUrl('data:image/png;base64,' + backImgBase)" alt="" style="width: 100%; height: 100%; display: block" @error="$fileRetry('data:image/png;base64,' + backImgBase)" />
|
||||
<div v-show="showRefresh" class="verify-refresh" @click="refresh"><i class="iconfont icon-refresh"></i></div>
|
||||
<transition name="tips">
|
||||
<span v-if="tipWords" :class="passFlag ? 'suc-bg' : 'err-bg'" class="verify-tips">{{ tipWords }}</span>
|
||||
@@ -46,10 +46,10 @@
|
||||
class="verify-sub-block"
|
||||
>
|
||||
<img
|
||||
:src="'data:image/png;base64,' + blockBackImgBase"
|
||||
:src="$fileUrl('data:image/png;base64,' + blockBackImgBase)"
|
||||
alt=""
|
||||
style="width: 100%; height: 100%; display: block; -webkit-user-drag: none"
|
||||
/>
|
||||
@error="$fileRetry('data:image/png;base64,' + blockBackImgBase)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<video-play
|
||||
ref="playerRef"
|
||||
v-bind="options"
|
||||
:src="src"
|
||||
:src="$fileUrl(src)"
|
||||
:poster="$fileUrl(poster)"
|
||||
@error="$fileRetry(src)"
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@timeupdate="onTimeupdate"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div v-if="objData.type === 'image'">
|
||||
<div class="waterfall" v-loading="state.loading">
|
||||
<div class="waterfall-item" v-for="item in state.dataList" :key="item.mediaId">
|
||||
<img class="material-img" :src="item.url" />
|
||||
<img class="material-img" :src="$fileUrl(item.url)" @error="$fileRetry(item.url)" />
|
||||
<p class="item-name">{{ item.name }}</p>
|
||||
<el-row class="ope-row">
|
||||
<el-button type="success" @click="selectMaterial(item)"
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
<div v-if="item.repType === 'text'">{{ item.repContent }}</div>
|
||||
<div v-if="item.repType === 'image'">
|
||||
<a target="_blank" :href="item.repUrl"><img :src="item.repUrl" style="width: 100px" /></a>
|
||||
<a target="_blank" :href="$fileUrl(item.repUrl)"><img :src="$fileUrl(item.repUrl)" style="width: 100px" @error="$fileRetry(item.repUrl)" /></a>
|
||||
</div>
|
||||
<div v-if="item.repType === 'voice'">
|
||||
<SvgIcon name="local-wx-voice" :size="80" @click="loadVideo(item)"></SvgIcon>
|
||||
@@ -54,7 +54,7 @@
|
||||
<el-link
|
||||
type="primary"
|
||||
target="_blank"
|
||||
:href="
|
||||
:href="$fileUrl(
|
||||
'https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=' +
|
||||
item.repLocationY +
|
||||
'&pointy=' +
|
||||
@@ -62,23 +62,29 @@
|
||||
'&name=' +
|
||||
item.repContent +
|
||||
'&ref=joolun'
|
||||
"
|
||||
)"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
:src="$fileUrl(
|
||||
'https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|' +
|
||||
item.repLocationX +
|
||||
',' +
|
||||
item.repLocationY +
|
||||
'&key=PFFBZ-RBM3V-IEEPP-UH6KE-6QUQE-C4BVJ&size=250*180'
|
||||
"
|
||||
/>
|
||||
)"
|
||||
@error="$fileRetry(
|
||||
'https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|' +
|
||||
item.repLocationX +
|
||||
',' +
|
||||
item.repLocationY +
|
||||
'&key=PFFBZ-RBM3V-IEEPP-UH6KE-6QUQE-C4BVJ&size=250*180'
|
||||
)" />
|
||||
<p />
|
||||
<i class="el-icon-map-location"></i>{{ item.repContent }}
|
||||
</el-link>
|
||||
</div>
|
||||
<div v-if="item.repType === 'link'" class="avue-card__detail">
|
||||
<el-link type="success" :underline="false" target="_blank" :href="item.repUrl">
|
||||
<el-link type="success" :underline="false" target="_blank" :href="$fileUrl(item.repUrl)">
|
||||
<div class="avue-card__title"><i class="el-icon-link"></i>{{ item.repName }}</div>
|
||||
</el-link>
|
||||
<div class="avue-card__info" style="height: unset">{{ item.repDesc }}</div>
|
||||
@@ -87,9 +93,9 @@
|
||||
<wx-news :obj-data="JSON.parse(item.content)"></wx-news>
|
||||
</div>
|
||||
<div v-if="item.repType === 'music'">
|
||||
<el-link type="success" :underline="false" target="_blank" :href="item.repUrl">
|
||||
<el-link type="success" :underline="false" target="_blank" :href="$fileUrl(item.repUrl)">
|
||||
<div class="avue-card__body" style="padding: 10px; background-color: #fff; border-radius: 5px">
|
||||
<div class="avue-card__avatar"><img :src="item.repThumbUrl" alt="" /></div>
|
||||
<div class="avue-card__avatar"><img :src="$fileUrl(item.repThumbUrl)" alt="" @error="$fileRetry(item.repThumbUrl)" /></div>
|
||||
<div class="avue-card__detail">
|
||||
<div class="avue-card__title" style="margin-bottom: unset">{{ item.repName }}</div>
|
||||
<div class="avue-card__info" style="height: unset">{{ item.repDesc }}</div>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<template>
|
||||
<div class="news-home">
|
||||
<div v-for="(news, index) in props.objData" :key="index" class="news-div">
|
||||
<a v-if="index === 0" target="_blank" :href="news.url">
|
||||
<a v-if="index === 0" target="_blank" :href="$fileUrl(news.url)">
|
||||
<div class="news-main">
|
||||
<div class="news-content">
|
||||
<img class="material-img" :src="news.thumbUrl" style="width: 280; height: 120" />
|
||||
<img class="material-img" :src="$fileUrl(news.thumbUrl)" style="width: 280; height: 120" @error="$fileRetry(news.thumbUrl)" />
|
||||
<div class="news-content-title">
|
||||
<span>{{ news.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a v-if="index > 0" target="_blank" :href="news.url">
|
||||
<a v-if="index > 0" target="_blank" :href="$fileUrl(news.url)">
|
||||
<div class="news-main-item">
|
||||
<div class="news-content-item">
|
||||
<div class="news-content-item-title">{{ news.title }}</div>
|
||||
<div class="news-content-item-img">
|
||||
<img class="material-img" :src="news.thumbUrl" height="100%" />
|
||||
<img class="material-img" :src="$fileUrl(news.thumbUrl)" height="100%" @error="$fileRetry(news.thumbUrl)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<template #label><i class="el-icon-picture"></i> 图片</template>
|
||||
<el-row>
|
||||
<div v-if="objData.repUrl" class="select-item">
|
||||
<img class="material-img" :src="objData.repUrl" />
|
||||
<img class="material-img" :src="$fileUrl(objData.repUrl)" @error="$fileRetry(objData.repUrl)" />
|
||||
<p v-if="objData.repName" class="item-name">{{ objData.repName }}</p>
|
||||
<el-row class="ope-row">
|
||||
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
|
||||
@@ -36,7 +36,7 @@
|
||||
<div v-if="objData.repName" class="select-item">
|
||||
<p class="item-name">{{ objData.repName }}</p>
|
||||
<div class="item-infos">
|
||||
<img :src="WxVoice" style="width: 100px" @click="loadVideo(item)" />
|
||||
<img :src="$fileUrl(WxVoice)" style="width: 100px" @click="loadVideo(item)" @error="$fileRetry(WxVoice)" />
|
||||
</div>
|
||||
<el-row class="ope-row">
|
||||
<el-button type="danger" icon="el-icon-delete" circle @click="deleteObj"></el-button>
|
||||
@@ -65,7 +65,7 @@
|
||||
</el-row>
|
||||
<el-row style="text-align: center">
|
||||
<el-col :span="12" class="col-select">
|
||||
<a v-if="objData.repUrl" target="_blank" :href="objData.repUrl"> <SvgIcon name="local-wx-video" :size="45" /> </a
|
||||
<a v-if="objData.repUrl" target="_blank" :href="$fileUrl(objData.repUrl)"> <SvgIcon name="local-wx-video" :size="45" /> </a
|
||||
></el-col>
|
||||
<el-col :span="12" class="col-add">
|
||||
<el-button type="success" @click="openMaterial({ type: 'video', accountId: props.objData.appId })">素材库选择 </el-button>
|
||||
|
||||
@@ -14,6 +14,7 @@ import Tip from "/@/components/Tip/index.vue"
|
||||
import TagList from "/@/components/TagList/index.vue"
|
||||
import SvgIcon from "/@/components/SvgIcon/index.vue"
|
||||
import PreviewImage from "/@/components/PreviewImage/index.vue"
|
||||
import FileImage from "/@/components/FileImage/index.vue"
|
||||
import PreviewJson from "/@/components/PreviewJson/index.vue"
|
||||
import PreviewText from "/@/components/PreviewText/index.vue"
|
||||
import PreviewXml from "/@/components/PreviewXml/index.vue"
|
||||
@@ -86,6 +87,7 @@ export default {
|
||||
app.component("TagList", TagList)
|
||||
app.component("SvgIcon", SvgIcon)
|
||||
app.component("PreviewImage", PreviewImage)
|
||||
app.component("FileImage", FileImage)
|
||||
app.component("PreviewJson", PreviewJson)
|
||||
app.component("PreviewText", PreviewText)
|
||||
app.component("PreviewXml", PreviewXml)
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div :class="themeConfig.columnsAsideLayout" v-else>
|
||||
<a :href="v.meta.isLink" target="_blank">
|
||||
<a :href="$fileUrl(v.meta.isLink)" target="_blank">
|
||||
<SvgIcon :name="v.meta.icon" />
|
||||
<div class="columns-vertical-title font12">
|
||||
{{
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div v-show="state.isShowLoockLogin" class="layout-lock-screen-login">
|
||||
<div class="layout-lock-screen-login-box">
|
||||
<div class="layout-lock-screen-login-box-img">
|
||||
<img :src="baseURL + formData.avatar" />
|
||||
<img :src="$fileUrl(formData.avatar)" @error="$fileRetry(formData.avatar)" />
|
||||
</div>
|
||||
<div class="layout-lock-screen-login-box-name">{{ formData.username }}</div>
|
||||
<div class="layout-lock-screen-login-box-value">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<span :style="{color:setFontColor}">{{ themeConfig.globalTitle }}</span>
|
||||
</div>
|
||||
<div class="layout-logo-size" v-else @click="onThemeConfigChange">
|
||||
<img :src="logoMini" class="layout-logo-size-img"/>
|
||||
<img :src="$fileUrl(logoMini)" class="layout-logo-size-img" @error="$fileRetry(logoMini)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
</div>
|
||||
<el-dropdown :show-timeout="70" :hide-timeout="50" @command="onHandleCommandClick">
|
||||
<span class="layout-navbars-breadcrumb-user-link">
|
||||
<img :src="baseURL + userInfos.user.avatar" class="layout-navbars-breadcrumb-user-link-photo mr5" />
|
||||
<img :src="$fileUrl(userInfos.user.avatar)" class="layout-navbars-breadcrumb-user-link-photo mr5" @error="$fileRetry(userInfos.user.avatar)" />
|
||||
{{ userInfos.user.username }}
|
||||
<el-icon class="el-icon--right">
|
||||
<ele-ArrowDown />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { installFileAccess } from './utils/file-access'
|
||||
import { downBlobFile } from './utils/other'
|
||||
import { createApp } from "vue"
|
||||
import pinia from "/@/stores/index"
|
||||
import App from "./App.vue"
|
||||
@@ -23,6 +25,7 @@ import "/@/theme/tailwind.css"
|
||||
import "/@/theme/index.scss"
|
||||
|
||||
const app = createApp(App)
|
||||
installFileAccess(app, downBlobFile)
|
||||
|
||||
// 全局自定方法挂载
|
||||
properties(app)
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import 'vue'
|
||||
declare module '@vue/runtime-core' {
|
||||
interface ComponentCustomProperties {
|
||||
$fileUrl: (source?: string | null) => string
|
||||
$fileUrls: (sources: any) => string[]
|
||||
$fileHtml: (html: string) => string
|
||||
$fileNodes: (nodes: any) => any
|
||||
$fileRetry: (source?: string | null) => Promise<boolean>
|
||||
}
|
||||
}
|
||||
export {}
|
||||
@@ -0,0 +1,163 @@
|
||||
import * as CryptoJS from 'crypto-js'
|
||||
|
||||
export const FILE_ACCESS_FIELDS = ['appId', 'kid', 'ts', 'sign']
|
||||
const FILE_PATHS = ['/sys-file/oss/file', '/admin/sys-file/oss/file', '/api/admin/sys-file/oss/file']
|
||||
export interface FileAccessOptions {
|
||||
appId: string; kid: string; key: string; baseUrl: () => string
|
||||
allowedOrigins?: string[]; now?: () => number
|
||||
}
|
||||
const decode = (s: string) => decodeURIComponent(s.replace(/\+/g, ' '))
|
||||
const encode = (s: string) => encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
|
||||
const origin = (s: string) => {
|
||||
const match = /^(https?):\/\/([^/?#]+)/i.exec(s)
|
||||
if (!match || match[2].includes('@')) return ''
|
||||
return (match[1] + '://' + match[2]).toLowerCase().replace(match[1].toLowerCase() === 'https' ? /:443$/ : /:80$/, '')
|
||||
}
|
||||
export function fileAccessCanonical(file: string, app: string, kid: string, ts: number): string {
|
||||
const name = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(file)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
return ['v1', 'oss-file-read', app, kid, String(ts), name].join('\n')
|
||||
}
|
||||
export function fileAccessSignature(file: string, app: string, kid: string, ts: number, key: string): string {
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(key) || CryptoJS.enc.Base64.parse(key).sigBytes < 32) throw new Error('Invalid file access key')
|
||||
return CryptoJS.HmacSHA256(fileAccessCanonical(file, app, kid, ts), CryptoJS.enc.Base64.parse(key)).toString(CryptoJS.enc.Hex)
|
||||
}
|
||||
export function fileUploadCanonical(target: string, app: string, kid: string, ts: number, nonce: string): string {
|
||||
if (!['upload', 'inner-upload', 'qn-upload'].includes(target)) throw new Error('Invalid file upload target')
|
||||
return ['v1', 'oss-file-upload', 'POST', target, app, kid, String(ts), nonce].join('\n')
|
||||
}
|
||||
export function fileUploadSignature(target: string, app: string, kid: string, ts: number, nonce: string, key: string): string {
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(key) || CryptoJS.enc.Base64.parse(key).sigBytes < 32) throw new Error('Invalid file upload key')
|
||||
return CryptoJS.HmacSHA256(fileUploadCanonical(target, app, kid, ts, nonce), CryptoJS.enc.Base64.parse(key)).toString(CryptoJS.enc.Hex)
|
||||
}
|
||||
export function createFileAccess(options: FileAccessOptions) {
|
||||
const now = options.now || Date.now
|
||||
let offset = 0, ttl = 1800
|
||||
const cache = new Map<string, { url: string; ts: number }>()
|
||||
let uploadSequence = 0
|
||||
const base = () => (options.baseUrl() || '').replace(/\/+$/, '')
|
||||
const allowed = () => [origin(base()), ...(options.allowedOrigins || []).map(origin)].filter(Boolean)
|
||||
function resolve(source?: string | null): string {
|
||||
const raw = typeof source === 'string' ? source.trim() : ''
|
||||
if (!raw || /^(?:data:|blob:|file:|wxfile:|content:|\/static\/|static\/|\/assets\/|\/src\/|@\/|#)/i.test(raw)) return raw
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return raw
|
||||
if (raw.startsWith('//')) return (base().startsWith('http:') ? 'http:' : 'https:') + raw
|
||||
const root = base()
|
||||
if (/^[^/?#:\\]+\.[a-z0-9]{1,10}$/i.test(raw)) return root + '/admin/sys-file/oss/file?fileName=' + encode(raw)
|
||||
const path = raw.startsWith('/') ? raw : '/' + raw
|
||||
if (/^(?:\/api)?\/admin\/sys-file\/oss\/(?:file|access-info)(?:[?#]|$)/.test(path)) return root + path.replace(/^\/api\//, '/')
|
||||
const prefix = root.replace(/^https?:\/\/[^/?#]+/i, '')
|
||||
if ((prefix && path.startsWith(prefix + '/')) || path.startsWith('/api/')) return (origin(root) || '') + path
|
||||
if (path.startsWith('/sys-file/oss/')) return root + '/admin' + path
|
||||
if (raw.startsWith('?')) return root + '/admin/sys-file/oss/file' + raw
|
||||
return root + path
|
||||
}
|
||||
function parse(source?: string | null) {
|
||||
const resolved = resolve(source)
|
||||
const ownOrigin = origin(resolved)
|
||||
if (ownOrigin && !allowed().includes(ownOrigin)) return null
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(resolved) && !ownOrigin) return null
|
||||
const relative = ownOrigin ? resolved.replace(/^https?:\/\/[^/?#]+/i, '') : resolved
|
||||
const path = relative.split(/[?#]/)[0]
|
||||
const prefix = base().replace(/^https?:\/\/[^/?#]+/i, '')
|
||||
if (!FILE_PATHS.includes(path) && path !== prefix + '/admin/sys-file/oss/file') return null
|
||||
const hashAt = resolved.indexOf('#'), head = hashAt < 0 ? resolved : resolved.slice(0, hashAt)
|
||||
const hash = hashAt < 0 ? '' : resolved.slice(hashAt)
|
||||
const question = head.indexOf('?')
|
||||
const endpoint = question < 0 ? head : head.slice(0, question)
|
||||
const pairs = (question < 0 ? '' : head.slice(question + 1)).split('&').filter(Boolean).map(pair => {
|
||||
const at = pair.indexOf('='); return [decode(at < 0 ? pair : pair.slice(0, at)), decode(at < 0 ? '' : pair.slice(at + 1))]
|
||||
})
|
||||
const names = pairs.filter(([key]) => key === 'fileName')
|
||||
if (names.length !== 1 || !names[0][1] || /[\r\n]/.test(names[0][1])) throw new Error('Invalid file-center URL')
|
||||
const extras = pairs.filter(([key]) => key !== 'fileName' && !FILE_ACCESS_FIELDS.includes(key))
|
||||
const unsigned = endpoint + '?' + [...extras, ['fileName', names[0][1]]].map(([k, v]) => encode(k) + '=' + encode(v)).join('&') + hash
|
||||
return { endpoint, file: names[0][1], extras, hash, unsigned }
|
||||
}
|
||||
function url(source?: string | null, force = false): string {
|
||||
const parsed = parse(source)
|
||||
if (!parsed) return resolve(source)
|
||||
const seconds = Math.floor((now() + offset) / 1000), cached = cache.get(parsed.unsigned)
|
||||
if (!force && cached && seconds >= cached.ts - 300 && seconds < cached.ts + ttl - Math.min(300, ttl / 6)) return cached.url
|
||||
if (!options.appId || !options.kid) throw new Error('Missing file access configuration')
|
||||
const sign = fileAccessSignature(parsed.file, options.appId, options.kid, seconds, options.key)
|
||||
const pairs = [...parsed.extras, ['appId', options.appId], ['kid', options.kid], ['ts', String(seconds)], ['sign', sign], ['fileName', parsed.file]]
|
||||
const result = parsed.endpoint + '?' + pairs.map(([k, v]) => encode(k) + '=' + encode(v)).join('&') + parsed.hash
|
||||
if (cache.size >= 2048) cache.delete(cache.keys().next().value as string)
|
||||
cache.set(parsed.unsigned, { url: result, ts: seconds })
|
||||
return result
|
||||
}
|
||||
function strip(source?: string | null): string {
|
||||
if (!source) return source || ''
|
||||
const parsed = parse(source)
|
||||
if (!parsed) return source
|
||||
// Preserve the original origin/path form in persisted values.
|
||||
const hashAt = source.indexOf('#'), hash = hashAt < 0 ? '' : source.slice(hashAt)
|
||||
const head = hashAt < 0 ? source : source.slice(0, hashAt), at = head.indexOf('?')
|
||||
if (at < 0) return source
|
||||
const query = head.slice(at + 1).split('&').filter(p => !FILE_ACCESS_FIELDS.includes(decode(p.split('=')[0])))
|
||||
return head.slice(0, at) + (query.length ? '?' + query.join('&') : '') + hash
|
||||
}
|
||||
function uploadTarget(source: string): string | null {
|
||||
const resolved = resolve(source), host = origin(resolved)
|
||||
if ((host && !allowed().includes(host)) || (/^[a-z][a-z0-9+.-]*:/i.test(resolved) && !host)) return null
|
||||
const path = (host ? resolved.replace(/^https?:\/\/[^/?#]+/i, '') : resolved).split(/[?#]/)[0]
|
||||
const prefix = base().replace(/^https?:\/\/[^/?#]+/i, '')
|
||||
for (const [target, suffix] of [['upload', 'upload'], ['inner-upload', 'inner-upload'], ['qn-upload', 'qn/upload']]) {
|
||||
if (['/sys-file/', '/admin/sys-file/', '/api/sys-file/', '/api/admin/sys-file/', prefix + '/admin/sys-file/', prefix + '/sys-file/']
|
||||
.some(start => path === start + suffix)) return target
|
||||
}
|
||||
return null
|
||||
}
|
||||
function uploadHeaders(source: string, fixedNonce?: string): Record<string, string> {
|
||||
const target = uploadTarget(source)
|
||||
if (!target) return {}
|
||||
const seconds = Math.floor((now() + offset) / 1000)
|
||||
// The nonce is a unique request identifier, not a secret; HMAC provides authorization.
|
||||
const nonce = fixedNonce || Math.floor(now()).toString(36) + '-' + (++uploadSequence).toString(36) + '-'
|
||||
+ Array.from({ length: 4 }, () => Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, '0')).join('')
|
||||
return {
|
||||
'X-File-App-Id': options.appId, 'X-File-Key-Id': options.kid, 'X-File-Timestamp': String(seconds), 'X-File-Nonce': nonce,
|
||||
'X-File-Signature': fileUploadSignature(target, options.appId, options.kid, seconds, nonce, options.key),
|
||||
}
|
||||
}
|
||||
function synchronize(serverTime: number, ttlSeconds = ttl, roundTripMs = 0) {
|
||||
if (!Number.isFinite(serverTime) || serverTime <= 0) return
|
||||
offset = serverTime + roundTripMs / 2 - now()
|
||||
if (Number.isFinite(ttlSeconds) && ttlSeconds > 300 && ttlSeconds <= 86400) ttl = ttlSeconds
|
||||
// url() already checks cached timestamps against the corrected clock. Keep
|
||||
// signatures that are still usable so a small clock correction cannot reload every image.
|
||||
}
|
||||
function invalidate(source?: string | null) {
|
||||
const parsed = parse(source)
|
||||
if (parsed) cache.delete(parsed.unsigned)
|
||||
}
|
||||
return { uploadHeaders, uploadTarget, url, resolve, strip, protected: (s?: string | null) => !!parse(s), synchronize, invalidate, clear: () => cache.clear(), ttl: () => ttl }
|
||||
}
|
||||
|
||||
const unescapeAttribute = (s: string) => s.replace(/&/gi, '&').replace(/"/gi, '"').replace(/&#(?:39|x27);/gi, "'").replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n); return code <= 0x10ffff ? String.fromCodePoint(code) : ''; })
|
||||
const escapeAttribute = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')
|
||||
/** Rewrite only resource attributes; no DOM/window dependency (also used by mini programs). */
|
||||
export function rewriteFileHtml(html: string, transform: (url: string) => string): string {
|
||||
if (typeof html !== 'string' || !html) return html
|
||||
return html.replace(/<[a-z][^>]*>/gi, tag => tag.replace(/\b(src|href|poster|srcset)\s*=\s*(["'])([\s\S]*?)\2/gi, (attribute, name, quote, raw) => {
|
||||
const value = unescapeAttribute(raw)
|
||||
const next = name.toLowerCase() === 'srcset' && !value.startsWith('data:')
|
||||
? value.split(',').map(item => item.trim().replace(/^\S+/, transform)).join(', ')
|
||||
: transform(value)
|
||||
return next === value ? attribute : name + '=' + quote + escapeAttribute(next) + quote
|
||||
}).replace(/url\(\s*(["']?)([^)'"\s]+)\1\s*\)/gi, (all, quote, value) => {
|
||||
const next = transform(unescapeAttribute(value)); return next === value ? all : 'url(' + quote + escapeAttribute(next) + quote + ')'
|
||||
}))
|
||||
}
|
||||
export function rewriteFileNodes(nodes: any, transform: (url: string) => string): any {
|
||||
if (typeof nodes === 'string') return rewriteFileHtml(nodes, transform)
|
||||
if (Array.isArray(nodes)) return nodes.map(node => rewriteFileNodes(node, transform))
|
||||
if (!nodes || typeof nodes !== 'object') return nodes
|
||||
const copy = { ...nodes }
|
||||
if (copy.attrs) {
|
||||
copy.attrs = { ...copy.attrs }
|
||||
for (const key of ['src', 'href', 'poster']) if (typeof copy.attrs[key] === 'string') copy.attrs[key] = transform(copy.attrs[key])
|
||||
}
|
||||
if (copy.children) copy.children = rewriteFileNodes(copy.children, transform)
|
||||
return copy
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { watch } from 'vue';
|
||||
import { accessFileUrl, fileAccessRevision, retryFileAccess } from './file-access';
|
||||
|
||||
/** Renew editor resources without replacing the document or changing its undo history. */
|
||||
export function bindFileAccessDom(root: HTMLElement) {
|
||||
let scheduled = false;
|
||||
let stopped = false;
|
||||
const refresh = () => {
|
||||
if (stopped) return;
|
||||
for (const node of root.querySelectorAll<HTMLElement>('[src], [href], [poster]')) {
|
||||
for (const attr of ['src', 'href', 'poster']) {
|
||||
const source = node.getAttribute(attr);
|
||||
if (!source) continue;
|
||||
const target = accessFileUrl(source);
|
||||
if (target !== source) node.setAttribute(attr, target);
|
||||
}
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scheduled || stopped) return;
|
||||
scheduled = true;
|
||||
queueMicrotask(() => { scheduled = false; refresh(); });
|
||||
};
|
||||
const observer = new MutationObserver(schedule);
|
||||
observer.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['src', 'href', 'poster'] });
|
||||
const unwatch = watch(fileAccessRevision, schedule, { flush: 'post' });
|
||||
const onError = (event: Event) => {
|
||||
const node = event.target;
|
||||
if (node instanceof HTMLElement) void retryFileAccess(node.getAttribute('src')).then(schedule);
|
||||
};
|
||||
root.addEventListener('error', onError, true);
|
||||
refresh();
|
||||
return () => {
|
||||
stopped = true;
|
||||
observer.disconnect();
|
||||
unwatch();
|
||||
root.removeEventListener('error', onError, true);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ref } from 'vue'
|
||||
import { createFileAccess, rewriteFileHtml, rewriteFileNodes } from './file-access-core'
|
||||
|
||||
|
||||
export const fileAccessRevision = ref(0)
|
||||
const client = createFileAccess({
|
||||
appId: import.meta.env.VITE_FILE_ACCESS_APP_ID,
|
||||
kid: import.meta.env.VITE_FILE_ACCESS_KEY_ID,
|
||||
key: import.meta.env.VITE_FILE_ACCESS_KEY,
|
||||
baseUrl: () => import.meta.env.VITE_IMAGE_BASE_PATH || import.meta.env.VITE_API_URL || '/api',
|
||||
allowedOrigins: [import.meta.env.VITE_IMAGE_BASE_PATH, typeof location === 'undefined' ? '' : location.origin, ...(import.meta.env.VITE_FILE_ACCESS_ALLOWED_ORIGINS || '').split(',')],
|
||||
})
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let pending: Promise<void> | undefined
|
||||
let lastSync = 0
|
||||
const retried = new Map<string, number>()
|
||||
const retrying = new Map<string, Promise<boolean>>()
|
||||
export function fileUrl(source?: string | null): string {
|
||||
void fileAccessRevision.value
|
||||
try { return client.url(source) } catch { return '' }
|
||||
}
|
||||
export const accessFileUrl = (source?: string | null) => isProtectedFile(source) ? fileUrl(source) : (source || '')
|
||||
export const fileUrls = (sources: any) => Array.isArray(sources) ? sources.map(accessFileUrl) : []
|
||||
export const originalFileUrl = (source?: string | null) => client.strip(source)
|
||||
export const resolveFileUrl = (source?: string | null) => client.resolve(source)
|
||||
export const fileUploadHeaders = (source: string) => client.uploadHeaders(source)
|
||||
export const fileHtml = (html: string) => rewriteFileHtml(html, accessFileUrl)
|
||||
export const stripFileHtml = (html: string) => rewriteFileHtml(html, originalFileUrl)
|
||||
export const fileNodes = (nodes: any) => rewriteFileNodes(nodes, accessFileUrl)
|
||||
export const isProtectedFile = (source?: string | null) => { try { return client.protected(source) } catch { return false } }
|
||||
export async function refreshFileAccess(force = false): Promise<void> {
|
||||
if (pending) return pending
|
||||
if (!force && Date.now() - lastSync < 60000) return
|
||||
pending = (async () => {
|
||||
const started = Date.now()
|
||||
try {
|
||||
const result: any = await readAccessInfo()
|
||||
const info = result?.data || result
|
||||
if (info?.serverTime) client.synchronize(Number(info.serverTime), Number(info.ttlSeconds), Date.now() - started)
|
||||
lastSync = Date.now()
|
||||
} catch { /* Retain the last clock offset and signed access; never fall back to a bare URL. */ }
|
||||
finally { fileAccessRevision.value++; pending = undefined }
|
||||
})()
|
||||
return pending
|
||||
}
|
||||
export function startFileAccess() {
|
||||
if (!timer) timer = setInterval(() => { fileAccessRevision.value++ }, 60000)
|
||||
void refreshFileAccess()
|
||||
}
|
||||
export function stopFileAccess() { if (timer) clearInterval(timer); timer = undefined }
|
||||
export async function retryFileAccess(source?: string | null): Promise<boolean> {
|
||||
if (!isProtectedFile(source)) return false
|
||||
const raw = originalFileUrl(source), previous = retried.get(raw) || 0
|
||||
const active = retrying.get(raw)
|
||||
if (active) return active
|
||||
if (Date.now() - previous < 30 * 60 * 1000) return false
|
||||
if (retried.size >= 2048) retried.clear()
|
||||
retried.set(raw, Date.now())
|
||||
const task = (async () => {
|
||||
const previousUrl = fileUrl(source)
|
||||
await refreshFileAccess(true)
|
||||
client.invalidate(source)
|
||||
if (fileUrl(source) === previousUrl) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1100))
|
||||
client.invalidate(source)
|
||||
}
|
||||
fileAccessRevision.value++
|
||||
return true
|
||||
})()
|
||||
retrying.set(raw, task)
|
||||
try { return await task } finally { retrying.delete(raw) }
|
||||
}
|
||||
type FileDownloader = (url: string, query: Record<string, never>, name: string) => unknown
|
||||
/** Keep owned file links inside the application's fetch/blob download flow. */
|
||||
export function handleFileLinkClick(event: MouseEvent, download: FileDownloader): boolean {
|
||||
const anchor = (event.target as Element | null)?.closest?.('a[href]')
|
||||
const href = anchor?.getAttribute('href')
|
||||
if (event.defaultPrevented || !href || !isProtectedFile(href)) return false
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
const name = /[?&]fileName=([^&#]*)/.exec(resolveFileUrl(href))
|
||||
const filename = anchor?.getAttribute('download') || (name ? decodeURIComponent(name[1].replace(/\+/g, ' ')) : '文件')
|
||||
void download(originalFileUrl(href), {}, filename)
|
||||
return true
|
||||
}
|
||||
export function installFileAccess(app: any, download?: FileDownloader) {
|
||||
Object.assign(app.config.globalProperties, {
|
||||
$fileUrl: accessFileUrl, $fileUrls: fileUrls, $fileHtml: fileHtml, $fileNodes: fileNodes, $fileRetry: retryFileAccess,
|
||||
})
|
||||
startFileAccess()
|
||||
if (typeof window !== 'undefined') {
|
||||
if (download) document.addEventListener('click', event => { handleFileLinkClick(event, download) }, true)
|
||||
window.addEventListener('online', () => { retried.clear(); void refreshFileAccess(true) })
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) stopFileAccess()
|
||||
else { retried.clear(); startFileAccess() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function readAccessInfo() {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
try {
|
||||
const response = await fetch(client.resolve('/admin/sys-file/oss/access-info'), { credentials: 'omit', cache: 'no-store', signal: controller.signal })
|
||||
if (!response.ok) throw new Error('File clock unavailable')
|
||||
return await response.json()
|
||||
} finally { clearTimeout(timeout) }
|
||||
}
|
||||
+8
-2
@@ -1,3 +1,4 @@
|
||||
import { accessFileUrl, refreshFileAccess, isProtectedFile } from './file-access'
|
||||
import { nextTick } from "vue"
|
||||
import router from "/@/router/index"
|
||||
import pinia from "/@/stores/index"
|
||||
@@ -255,9 +256,10 @@ export function base64Encrypt(src: string) {
|
||||
* @param fileName 文件名称
|
||||
* @returns {*}
|
||||
*/
|
||||
export function downBlobFile(url: any, query: any, fileName: string) {
|
||||
export function downBlobFile(url: any, query: any, fileName: string, retried = false) {
|
||||
return request({
|
||||
url: url,
|
||||
url: accessFileUrl(url),
|
||||
...(isProtectedFile(url) ? { baseURL: '' } : {}),
|
||||
method: "get",
|
||||
responseType: "blob",
|
||||
params: query,
|
||||
@@ -266,6 +268,10 @@ export function downBlobFile(url: any, query: any, fileName: string) {
|
||||
handleBlobFile(response, fileName)
|
||||
})
|
||||
.catch(async (error) => {
|
||||
if (!retried && isProtectedFile(url)) {
|
||||
await refreshFileAccess(true)
|
||||
return downBlobFile(url, query, fileName, true)
|
||||
}
|
||||
let msg = "下载失败"
|
||||
try {
|
||||
if (error instanceof Blob) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Session } from "/@/utils/storage"
|
||||
import { useMessageBox } from "/@/hooks/message"
|
||||
import qs from "qs"
|
||||
import other from "./other"
|
||||
import { fileUploadHeaders } from './file-access'
|
||||
|
||||
/**
|
||||
* 创建并配置一个 Axios 实例对象
|
||||
@@ -48,6 +49,7 @@ service.interceptors.request.use(
|
||||
|
||||
// 自动适配单体和微服务架构不同的URL
|
||||
config.url = other.adaptationUrl(config.url)
|
||||
if (config.method?.toLowerCase() === 'post') Object.assign(config.headers, fileUploadHeaders(axios.getUri(config)))
|
||||
|
||||
// 处理完毕,返回config对象
|
||||
return config
|
||||
|
||||
@@ -7,31 +7,33 @@
|
||||
<div class="layout-padding-auto layout-padding-view">
|
||||
<el-row>
|
||||
<div class="mb8" style="width: 100%">
|
||||
<el-button @click="dicDialogRef.openDialog()" class="ml10" icon="folder-add" type="primary">
|
||||
<el-button @click="dicDialogRef.openDialog()" class="ml10" icon="folder-add"
|
||||
type="primary">
|
||||
{{ $t("common.addBtn") }}
|
||||
</el-button>
|
||||
<el-button plain @click="handleRefreshCache()" class="ml10" icon="refresh-left" type="primary">
|
||||
<el-button plain @click="handleRefreshCache()" class="ml10" icon="refresh-left"
|
||||
type="primary">
|
||||
{{ $t("common.refreshCacheBtn") }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-scrollbar>
|
||||
<query-tree ref="dictTreeRef" :query="state.queryList" @node-click="handleNodeClick" placeholder="请输入字典项或名称">
|
||||
<query-tree ref="dictTreeRef" :query="state.queryList" @node-click="handleNodeClick"
|
||||
placeholder="请输入字典项或名称">
|
||||
<template #default="{ data }">
|
||||
<span class="custom-tree-node">
|
||||
<span class="label">{{ data.description }}</span>
|
||||
<span class="code">{{ data.dictType }}</span>
|
||||
<span class="do">
|
||||
<el-button-group>
|
||||
<el-button icon="el-icon-edit" size="small" @click.stop="dicDialogRef.openDialog(data.id)"></el-button>
|
||||
<el-tooltip :content="$t('sysdict.deleteDisabledTip')" :disabled="data.systemFlag === '0'" placement="top">
|
||||
<el-button icon="el-icon-edit" size="small"
|
||||
@click.stop="dicDialogRef.openDialog(data.id)"></el-button>
|
||||
<el-tooltip :content="$t('sysdict.deleteDisabledTip')"
|
||||
:disabled="data.systemFlag === '0'" placement="top">
|
||||
<span style="margin-left: 12px">
|
||||
<el-button
|
||||
:disabled="data.systemFlag !== '0'"
|
||||
icon="el-icon-delete"
|
||||
size="small"
|
||||
@click.stop="handleDelete([data.id])"
|
||||
></el-button>
|
||||
<el-button :disabled="data.systemFlag !== '0'"
|
||||
icon="el-icon-delete" size="small"
|
||||
@click.stop="handleDelete([data.id])"></el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</el-button-group>
|
||||
@@ -41,9 +43,10 @@
|
||||
</query-tree>
|
||||
</el-scrollbar>
|
||||
<el-footer style="height: 40px; line-height: 40px">
|
||||
<el-button type="primary" size="small" icon="Download" style="width: 100%" @click="exportExcel">{{
|
||||
$t("common.exportBtn")
|
||||
}}</el-button>
|
||||
<el-button type="primary" size="small" icon="Download" style="width: 100%"
|
||||
@click="exportExcel">{{
|
||||
$t("common.exportBtn")
|
||||
}}</el-button>
|
||||
</el-footer>
|
||||
</div>
|
||||
</pane>
|
||||
@@ -130,6 +133,7 @@ const handleDelete = async (ids: string[]) => {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.custom-tree-node {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -139,16 +143,20 @@ const handleDelete = async (ids: string[]) => {
|
||||
padding-right: 24px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.custom-tree-node .code {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.custom-tree-node .do {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.custom-tree-node:hover .code {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.custom-tree-node:hover .do {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
v-model="state.queryForm.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
<div class="log-export-time-tip">导出需选择完整时间范围,最多支持一个自然月</div>
|
||||
</el-form-item>
|
||||
</SearchPanel>
|
||||
</el-row>
|
||||
@@ -109,15 +110,23 @@
|
||||
<pagination @current-change="currentChangeHandle" @size-change="sizeChangeHandle" v-bind="state.pagination"></pagination>
|
||||
<log-detail ref="LogDetailRef"></log-detail>
|
||||
</div>
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess"
|
||||
@failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { delObj, pageList } from "/@/api/admin/log"
|
||||
import { delObj, pageList, submitLogExport } from "/@/api/admin/log"
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { useI18n } from "vue-i18n"
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import dayjs from "dayjs"
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat"
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
const LogDetail = defineAsyncComponent(() => import("./detail.vue"))
|
||||
const LogLineChart = defineAsyncComponent(() => import("./line-chart.vue"))
|
||||
@@ -152,7 +161,6 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
|
||||
// table hook
|
||||
const {
|
||||
downBlobFile,
|
||||
getDataList,
|
||||
currentChangeHandle: baseCurrentChangeHandle,
|
||||
sortChangeHandle,
|
||||
@@ -175,8 +183,36 @@ const resetQuery = () => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const { exportProgressRef, exportTaskConfig, exportSubmitting, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/admin/async-task/progress", redisKey: "admin:export:log",
|
||||
taskName: "操作日志", fileName: "操作日志.xlsx", submitTask: submitLogExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => {
|
||||
downBlobFile("/admin/log/export", state.queryForm, "操作日志.xlsx")
|
||||
if (exportSubmitting.value) {
|
||||
exportProgressRef.value?.show()
|
||||
return
|
||||
}
|
||||
const range = state.queryForm.createTime
|
||||
let error = ""
|
||||
if (!Array.isArray(range) || range.length !== 2 || !range[0] || !range[1]) {
|
||||
error = "导出前请选择完整的日志创建时间范围"
|
||||
} else {
|
||||
const start = dayjs(range[0], "YYYY-MM-DD HH:mm:ss", true)
|
||||
const end = dayjs(range[1], "YYYY-MM-DD HH:mm:ss", true)
|
||||
if (!start.isValid() || !end.isValid()) error = "请选择有效的日志创建时间范围"
|
||||
else if (start.isAfter(end)) error = "日志创建时间的开始时间不能晚于结束时间"
|
||||
else if (end.isAfter(start.add(1, "month"))) error = "日志导出时间范围不能超过一个月"
|
||||
}
|
||||
if (error) {
|
||||
showSearch.value = true
|
||||
useMessage().error(error)
|
||||
return
|
||||
}
|
||||
return submitExport({
|
||||
...state.queryForm,
|
||||
createTime: range,
|
||||
})
|
||||
}
|
||||
|
||||
// 多选事件
|
||||
@@ -214,6 +250,13 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.log-export-time-tip {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
pre code.hljs {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
匹配结果
|
||||
<tip content="可点击敏感词加入白名单"/>
|
||||
</template>
|
||||
<div v-html="matchResult" @click="handleChildClick"/>
|
||||
<div v-html="$fileHtml(matchResult)" @click="handleChildClick"/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
@@ -58,11 +58,15 @@
|
||||
@refreshDataList="getDataList"
|
||||
/>
|
||||
</div>
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig"
|
||||
@success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="systemDept" setup>
|
||||
import { downBlobFile } from "/@/utils/other"
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { submitDeptExport } from "/@/api/admin/dept"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { useI18n } from "vue-i18n"
|
||||
import { useUserInfo } from "/@/stores/userInfo"
|
||||
|
||||
@@ -120,8 +124,13 @@ const handleExpand = () => {
|
||||
/**
|
||||
* 导出Excel
|
||||
*/
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/admin/async-task/progress", redisKey: "admin:export:dept",
|
||||
taskName: "部门管理", fileName: "部门管理.xlsx", submitTask: submitDeptExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => {
|
||||
downBlobFile("/admin/dept/export", state.queryForm, "部门管理.xlsx")
|
||||
return submitExport(state.queryForm)
|
||||
}
|
||||
|
||||
const getDataList = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-image :style="styles" v-bind="props" :src="src.includes('http') ? src : baseURL + src">
|
||||
<file-image :style="styles" v-bind="props" :src="$fileUrl(src)" @error="$fileRetry(src)">
|
||||
<template #placeholder>
|
||||
<div class="image-slot"></div>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@
|
||||
<el-icon><Picture /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</file-image>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex items-center news-title mx-[10px] my-[15px] text-[17px] font-medium">最新资讯</div>
|
||||
<div v-for="item in newsList" :key="item.id" class="news-card flex bg-white px-[10px] py-[16px] text-[#333] border-[#f2f2f2] border-b">
|
||||
<div class="mr-[10px]" v-if="item.image">
|
||||
<img :src="item.image.includes('http') ? item.image : baseURL + item.image" class="w-[120px] h-[90px]" />
|
||||
<img :src="$fileUrl(item.image)" class="w-[120px] h-[90px]" @error="$fileRetry(item.image)" />
|
||||
</div>
|
||||
<div class="flex flex-col justify-between flex-1">
|
||||
<div class="text-[15px] font-medium line-clamp-2">{{ item.title }}</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="pages-preview mx-[30px]">
|
||||
<div class="flex tabbar dark:bg-gray-700">
|
||||
<div class="flex flex-col items-center justify-center flex-1 tabbar-item" v-for="(item, index) in tabbar.list" :key="index">
|
||||
<img class="w-[22px] h-[22px]" :src="item.unselected.includes('http') ? item.unselected: baseURL + item.unselected" alt="" />
|
||||
<img class="w-[22px] h-[22px]" :src="$fileUrl(item.unselected)" alt="" @error="$fileRetry(item.unselected)" />
|
||||
<div class="leading-3 text-[12px] mt-[4px] dark:text-gray-300">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,10 +75,10 @@
|
||||
</div>
|
||||
<div v-if="scope.row.repType === 'text'">{{ scope.row.repContent }}</div>
|
||||
<div v-if="scope.row.repType === 'image'">
|
||||
<a target="_blank" :href="scope.row.repUrl"><img :src="scope.row.repUrl" style="width: 100px" /></a>
|
||||
<a target="_blank" :href="$fileUrl(scope.row.repUrl)"><img :src="$fileUrl(scope.row.repUrl)" style="width: 100px" @error="$fileRetry(scope.row.repUrl)" /></a>
|
||||
</div>
|
||||
<div v-if="['video', 'voice', 'link', 'shortvideo'].includes(scope.row.repType)">
|
||||
<el-tag>链接</el-tag>:<a :href="scope.row.repUrl" target="_blank">{{ scope.row.repName }}</a>
|
||||
<el-tag>链接</el-tag>:<a :href="$fileUrl(scope.row.repUrl)" target="_blank">{{ scope.row.repName }}</a>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div v-for="(news, index) in articlesAdd" :key="news.id">
|
||||
<div v-if="index == 0" class="news-main father" :class="{ activeAddNews: isActiveAddNews === index }" @click="activeNews(index)">
|
||||
<div class="news-content">
|
||||
<img v-if="news.thumbUrl" class="material-img" :src="news.thumbUrl" />
|
||||
<img v-if="news.thumbUrl" class="material-img" :src="$fileUrl(news.thumbUrl)" @error="$fileRetry(news.thumbUrl)" />
|
||||
<div class="news-content-title">{{ news.title }}</div>
|
||||
</div>
|
||||
<div v-if="articlesAdd.length > 1" class="child">
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="news-content-item">
|
||||
<div class="news-content-item-title">{{ news.title }}</div>
|
||||
<div class="news-content-item-img">
|
||||
<img v-if="news.thumbUrl" class="material-img" :src="news.thumbUrl" height="100%" />
|
||||
<img v-if="news.thumbUrl" class="material-img" :src="$fileUrl(news.thumbUrl)" height="100%" @error="$fileRetry(news.thumbUrl)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="child">
|
||||
@@ -52,9 +52,9 @@
|
||||
<img
|
||||
v-if="articlesAdd[isActiveAddNews].thumbUrl"
|
||||
class="material-img"
|
||||
:src="articlesAdd[isActiveAddNews].thumbUrl"
|
||||
:src="$fileUrl(articlesAdd[isActiveAddNews].thumbUrl)"
|
||||
:class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'"
|
||||
/>
|
||||
@error="$fileRetry(articlesAdd[isActiveAddNews].thumbUrl)" />
|
||||
<i v-else class="el-icon-plus avatar-uploader-icon" :class="isActiveAddNews === 0 ? 'avatar' : 'avatar1'"></i>
|
||||
<div class="thumb-but">
|
||||
<wx-file-upload :uploadData="uploadData" @success="handleImageChange"></wx-file-upload>
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
</div>
|
||||
<div v-loading="state.loading" class="waterfall">
|
||||
<div v-for="item in state.dataList" :key="item.id" class="waterfall-item">
|
||||
<a target="_blank" :href="item.url">
|
||||
<img class="material-img" :src="item.url" />
|
||||
<a target="_blank" :href="$fileUrl(item.url)">
|
||||
<img class="material-img" :src="$fileUrl(item.url)" @error="$fileRetry(item.url)" />
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
</a>
|
||||
<el-row class="ope-row">
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<td v-if="!loading" class="text-center" @click="addFood(1)">早餐</td>
|
||||
<div class="food_list">
|
||||
<td class="food_list_item" v-for="(item, index) in foodData1" :key="index" @click="delteData(item.tid)">
|
||||
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
||||
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="mx-1">|</span>
|
||||
@@ -56,7 +56,7 @@
|
||||
<td v-if="!loading" class="text-center" @click="addFood(2)">中餐</td>
|
||||
<div class="food_list">
|
||||
<td class="food_list_item" v-for="(item, index) in foodData2" :key="index" @click="delteData(item.tid)">
|
||||
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
||||
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="mx-1">|</span>
|
||||
@@ -70,7 +70,7 @@
|
||||
<td v-if="!loading" class="text-center" @click="addFood(3)">晚餐</td>
|
||||
<div class="food_list">
|
||||
<td class="food_list_item" v-for="(item, index) in foodData3" :key="index" @click="delteData(item.tid)">
|
||||
<el-image style="width: 80px; height: 80px" loading="lazy" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
||||
<file-image style="width: 80px; height: 80px" loading="lazy" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="mx-1">|</span>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
>
|
||||
<CircleClose />
|
||||
</el-icon>
|
||||
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
||||
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="mx-1">|</span>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<td v-if="!loading" class="text-center" @click="addFood(1)">早餐</td>
|
||||
<div class="food_list">
|
||||
<td class="food_list_item" v-for="(item, index) in foodData1" :key="index" @click="delteData(item.tid)">
|
||||
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
||||
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="mx-1">|</span>
|
||||
@@ -55,7 +55,7 @@
|
||||
<td v-if="!loading" class="text-center" @click="addFood(2)">中餐</td>
|
||||
<div class="food_list">
|
||||
<td class="food_list_item" v-for="(item, index) in foodData2" :key="index" @click="delteData(item.tid)">
|
||||
<el-image style="width: 80px; height: 80px" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
||||
<file-image style="width: 80px; height: 80px" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="mx-1">|</span>
|
||||
@@ -69,7 +69,7 @@
|
||||
<td v-if="!loading" class="text-center" @click="addFood(3)">晚餐</td>
|
||||
<div class="food_list">
|
||||
<td class="food_list_item" v-for="(item, index) in foodData3" :key="index" @click="delteData(item.tid)">
|
||||
<el-image style="width: 80px; height: 80px" loading="lazy" :src="imageBaseUrl + item.picUrl" fit="cover" />
|
||||
<file-image style="width: 80px; height: 80px" loading="lazy" :src="$fileUrl(imageBaseUrl + item.picUrl)" fit="cover" @error="$fileRetry(imageBaseUrl + item.picUrl)" />
|
||||
<div class="w-full flex justify-around items-center text-black mt-2">
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="mx-1">|</span>
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
<div class="w-full flex flex-wrap items-center" v-for="(hourType, index) in foodData" :key="index">
|
||||
<div v-for="dish in hourType" :key="dish.id" class="p-2" :style="cardStyle">
|
||||
<div class="relative overflow-hidden rounded-tl rounded-tr" :style="foodCardStyle">
|
||||
<img :src="imageBaseUrl + dish.picUrl" :alt="dish.name"
|
||||
class="h-full w-full object-cover" />
|
||||
<img :src="$fileUrl(imageBaseUrl + dish.picUrl)" :alt="dish.name"
|
||||
class="h-full w-full object-cover" @error="$fileRetry(imageBaseUrl + dish.picUrl)" />
|
||||
<div class="ribbon">午餐</div>
|
||||
<div class="absolute left-1 top-1">
|
||||
<star-medal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="form.perm === 'R'" v-html="form.props.value" />
|
||||
<div v-if="form.perm === 'R'" v-html="$fileHtml(form.props.value)" />
|
||||
<editor v-else v-model:get-html="form.props.value" :placeholder="form.placeholder" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="form.perm === 'R'">
|
||||
<img :src="form.props.value" />
|
||||
<img :src="$fileUrl(form.props.value)" @error="$fileRetry(form.props.value)" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<sign
|
||||
|
||||
@@ -734,10 +734,10 @@
|
||||
class="flex min-h-[180px] gap-4 rounded-[6px] border border-[var(--el-border-color-light)] bg-[var(--el-fill-color-blank)] p-3">
|
||||
<div
|
||||
class="flex h-[156px] w-40 flex-none items-center justify-center overflow-hidden rounded bg-[var(--el-fill-color-light)]">
|
||||
<el-image
|
||||
<file-image
|
||||
v-if="evidence.status === 'AVAILABLE' && evidence.imageUrl && !failedEvidenceImages.has(evidence.id)"
|
||||
class="h-full w-full" :src="evidenceImageUrl(evidence.imageUrl)"
|
||||
:preview-src-list="[evidenceImageUrl(evidence.imageUrl)]" fit="contain"
|
||||
class="h-full w-full" :src="$fileUrl(evidenceImageUrl(evidence.imageUrl))"
|
||||
:preview-src-list="$fileUrls([evidenceImageUrl(evidence.imageUrl)])" fit="contain"
|
||||
preview-teleported loading="lazy"
|
||||
@error="markEvidenceImageFailed(evidence.id)" />
|
||||
<span v-else-if="evidence.status === 'PENDING'"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:class="['nav-menu-item-top', item.id == currentActiveMenuId ? 'nav-menu-item-active' : '']"
|
||||
@click="handleClickMenvLevel1(item)"
|
||||
>
|
||||
<el-image class="nav-menu-item-icon" :src="imageBaseUrl + item.menuPic" fit="cover" />
|
||||
<file-image class="nav-menu-item-icon" :src="$fileUrl(imageBaseUrl + item.menuPic)" fit="cover" @error="$fileRetry(imageBaseUrl + item.menuPic)" />
|
||||
<div class="nav-menu-item-name">{{ $t(item.name) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
@@ -27,7 +27,7 @@
|
||||
:class="['nav-menu-item-bottom', item.id == curLevel2ActiveMenuId ? 'nav-menu-item-active' : '']"
|
||||
@click="handleClickBottomMenu(item)"
|
||||
>
|
||||
<el-image class="nav-menu-item-icon" :src="imageBaseUrl + item.menuPic" fit="cover" />
|
||||
<file-image class="nav-menu-item-icon" :src="$fileUrl(imageBaseUrl + item.menuPic)" fit="cover" @error="$fileRetry(imageBaseUrl + item.menuPic)" />
|
||||
<div class="nav-menu-item-name">
|
||||
{{ $t(item.name) }}
|
||||
<el-icon v-if="item.children?.length > 0"><CaretBottom /></el-icon>
|
||||
@@ -47,7 +47,7 @@
|
||||
:key="index"
|
||||
>
|
||||
<div v-if="!item.meta?.isHide" class="nav-menu-item-bottom" @click="handleClickNavMenuLevel3(item)">
|
||||
<el-image class="nav-menu-item-icon" :src="imageBaseUrl + item.menuPic" fit="cover" />
|
||||
<file-image class="nav-menu-item-icon" :src="$fileUrl(imageBaseUrl + item.menuPic)" fit="cover" @error="$fileRetry(imageBaseUrl + item.menuPic)" />
|
||||
<div class="nav-menu-item-name">{{ $t(item.name) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-600 text-center mt-8">© {{ currentNew.createBy }} {{ currentNew.createTime }}</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 text-justify mt-8 mb-6" v-html="currentNew.content"></p>
|
||||
<p class="text-sm text-gray-600 text-justify mt-8 mb-6" v-html="$fileHtml(currentNew.content)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
<el-card class="flex items-center justify-center h-[191px]">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative">
|
||||
<img class="object-cover w-16 h-16 rounded-full" :src="userData.avatar" alt="Avatar" />
|
||||
<img class="object-cover w-16 h-16 rounded-full" :src="$fileUrl(userData.avatar)" alt="Avatar" @error="$fileRetry(userData.avatar)" />
|
||||
<div class="absolute inset-0 rounded-full shadow-inner"></div>
|
||||
</div>
|
||||
<div class="ml-8">
|
||||
@@ -61,7 +61,7 @@ const initUserInfo = async (userId: any): Promise<void> => {
|
||||
userData.value = res.data; // 将查询到的数据保存到 userData 变量中
|
||||
userData.value.postName = res.data?.postList?.map((item: any) => item.postName).join(',') || ''; // 将 postList 中的 postName 合并成字符串并保存到 userData 变量中
|
||||
// 文件上传增加后端前缀
|
||||
userData.value.avatar = proxy.baseURL + res.data.avatar;
|
||||
userData.value.avatar = res.data.avatar;
|
||||
} finally {
|
||||
loading.value = false; // 结束加载状态
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="downloadUrl" label="下载地址" width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link :href="row.downloadUrl" target="_blank" :underline="false">打开</el-link>
|
||||
<el-link :href="$fileUrl(row.downloadUrl)" target="_blank" :underline="false">下载</el-link>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button text type="primary" icon="CopyDocument" @click="copyUrl(row.downloadUrl)">复制</el-button>
|
||||
</template>
|
||||
@@ -89,6 +89,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemAppVersion">
|
||||
import { accessFileUrl, isProtectedFile } from '/@/utils/file-access';
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import * as appVersionApi from "/@/api/integrated/app-version";
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message";
|
||||
@@ -157,8 +158,8 @@ const handleDelete = async (ids: string[]) => {
|
||||
|
||||
const copyUrl = async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url || '')
|
||||
useMessage().success('已复制下载链接')
|
||||
await navigator.clipboard.writeText(accessFileUrl(url))
|
||||
useMessage().success(isProtectedFile(url) ? '已复制临时文件地址,请通过应用加载,不支持浏览器直接打开' : '已复制下载链接')
|
||||
} catch (e) {
|
||||
useMessage().error('复制失败')
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<span class="tech-notice-time">{{ item.createTime?.slice(5, 16) }}</span>
|
||||
<span class="tech-notice-title" @click="toggleNoticeDetail(idx)">{{ item.title }}</span>
|
||||
</div>
|
||||
<div v-if=" hasValidDescription(item.description)" class="tech-notice-description" v-html="item.description"></div>
|
||||
<div v-if=" hasValidDescription(item.description)" class="tech-notice-description" v-html="$fileHtml(item.description)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</el-col>
|
||||
<el-col :span="1"></el-col>
|
||||
<el-col :span="8">
|
||||
<img :src="imgSrc" @click="getVerifyImageCode" />
|
||||
<img :src="$fileUrl(imgSrc)" @click="getVerifyImageCode" @error="$fileRetry(imgSrc)" />
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item class="mt-4 login-animation4">
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
<div class="relative transition-transform duration-300 transform group-hover:scale-110 group-hover:-translate-y-1">
|
||||
<img
|
||||
class="w-24 h-24 rounded-lg ring-gray-200 dark:ring-slate-700 dark:bg-slate-800"
|
||||
:src="!themeConfig.miniQr ? miniQr : baseURL + themeConfig.miniQr"
|
||||
:src="$fileUrl(!themeConfig.miniQr ? miniQr : themeConfig.miniQr)"
|
||||
:alt="t('scan.wechatApp')"
|
||||
/>
|
||||
@error="$fileRetry(!themeConfig.miniQr ? miniQr : themeConfig.miniQr)" />
|
||||
</div>
|
||||
<!-- 底部文字 -->
|
||||
<div class="mt-2">
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
>
|
||||
<img
|
||||
class="w-[85%] h-auto max-h-[650px] object-contain opacity-100 dark:opacity-75 transition-opacity duration-300 hover:opacity-90"
|
||||
:src="!themeConfig.background ? illustration : baseURL + themeConfig.background"
|
||||
:src="$fileUrl(!themeConfig.background ? illustration : themeConfig.background)"
|
||||
alt="Login illustration"
|
||||
/>
|
||||
@error="$fileRetry(!themeConfig.background ? illustration : themeConfig.background)" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧登录表单区域 -->
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view">
|
||||
<DraggableDepTree
|
||||
ref="draggableDepTreeRef"
|
||||
placeholder="请输入部门名称"
|
||||
:show-expand="true"
|
||||
@getSelectNode="handleGetSelectNode"
|
||||
switch-inactive-text="包含"
|
||||
switch-active-text="不包含"
|
||||
:defaultTreeWidth="20"
|
||||
:nodeClickable="false"
|
||||
>
|
||||
<DraggableDepTree ref="draggableDepTreeRef" placeholder="请输入部门名称" :show-expand="true"
|
||||
@getSelectNode="handleGetSelectNode" switch-inactive-text="包含" switch-active-text="不包含"
|
||||
:defaultTreeWidth="20" :nodeClickable="false">
|
||||
<template #default>
|
||||
<SearchPanel
|
||||
v-show="showSearch"
|
||||
ref="queryRef"
|
||||
:model="state.queryForm"
|
||||
:loading="Boolean(state.loading)"
|
||||
:search-text="$t('common.queryBtn')"
|
||||
:reset-text="$t('common.resetBtn')"
|
||||
@search="getDataList"
|
||||
@reset="resetQuery"
|
||||
>
|
||||
<SearchPanel v-show="showSearch" ref="queryRef" :model="state.queryForm"
|
||||
:loading="Boolean(state.loading)" :search-text="$t('common.queryBtn')"
|
||||
:reset-text="$t('common.resetBtn')" @search="getDataList" @reset="resetQuery">
|
||||
<el-form-item style="width: 300px" label="用户" prop="userId">
|
||||
<select-user v-model="state.queryForm.userId" />
|
||||
</el-form-item>
|
||||
@@ -39,37 +25,18 @@
|
||||
</SearchPanel>
|
||||
<el-row>
|
||||
<div class="mb8" style="width: 100%">
|
||||
<el-button
|
||||
plain
|
||||
:disabled="multiple"
|
||||
icon="Delete"
|
||||
type="primary"
|
||||
v-auth="'wallet_walletManage_del'"
|
||||
@click="handleDelete(selectObjs)"
|
||||
>
|
||||
<el-button plain :disabled="multiple" icon="Delete" type="primary"
|
||||
v-auth="'wallet_walletManage_del'" @click="handleDelete(selectObjs)">
|
||||
删除
|
||||
</el-button>
|
||||
<right-toolbar
|
||||
v-model:showSearch="showSearch"
|
||||
:export="'wallet_walletManage_export'"
|
||||
@exportExcel="exportExcel"
|
||||
class="ml10 mr20"
|
||||
style="float: right"
|
||||
@queryTable="getDataList"
|
||||
></right-toolbar>
|
||||
<right-toolbar v-model:showSearch="showSearch" :export="'wallet_walletManage_export'"
|
||||
@exportExcel="exportExcel" class="ml10 mr20" style="float: right"
|
||||
@queryTable="getDataList"></right-toolbar>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-table
|
||||
:data="state.dataList"
|
||||
v-loading="state.loading"
|
||||
border
|
||||
:summary-method="getSummaries"
|
||||
show-summary
|
||||
:cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle"
|
||||
@selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle"
|
||||
>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :summary-method="getSummaries"
|
||||
show-summary :cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
@selection-change="selectionChangHandle" @sort-change="sortChangeHandle">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="username" width="100" label="用户" />
|
||||
<el-table-column prop="userAvatar" width="80" label="头像">
|
||||
@@ -112,58 +79,41 @@
|
||||
</el-table-column>
|
||||
<el-table-column v-if="false" prop="activeBindStatus" width="120" label="饭卡绑定状态" />
|
||||
<el-table-column prop="createTime" width="200" label="创建时间" />
|
||||
<el-table-column
|
||||
label="操作"
|
||||
fixed="right"
|
||||
width="250"
|
||||
v-auths="[
|
||||
'wallet_walletManage_edit',
|
||||
'wallet_walletTransaction_update_balance',
|
||||
'wallet_walletRuleTransaction_transfer_accounts',
|
||||
]"
|
||||
>
|
||||
<el-table-column label="操作" fixed="right" width="250" v-auths="[
|
||||
'wallet_walletManage_edit',
|
||||
'wallet_walletTransaction_update_balance',
|
||||
'wallet_walletRuleTransaction_transfer_accounts',
|
||||
]">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-start">
|
||||
<el-button
|
||||
icon="InfoFilled"
|
||||
text
|
||||
type="primary"
|
||||
v-auth="'wallet_walletManage_edit'"
|
||||
@click="formDialogRef.openDialog(row.id)"
|
||||
>
|
||||
<el-button icon="InfoFilled" text type="primary" v-auth="'wallet_walletManage_edit'"
|
||||
@click="formDialogRef.openDialog(row.id)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
icon="Money"
|
||||
text
|
||||
type="primary"
|
||||
<el-button icon="Money" text type="primary"
|
||||
v-auth="'wallet_walletTransaction_update_balance'"
|
||||
@click="changeBalanceRef.openDialog(row.userId, row.id)"
|
||||
>
|
||||
@click="changeBalanceRef.openDialog(row.userId, row.id)">
|
||||
充值/扣款
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.isRule == 1"
|
||||
icon="SwitchFilled"
|
||||
text
|
||||
type="primary"
|
||||
<el-button v-if="row.isRule == 1" icon="SwitchFilled" text type="primary"
|
||||
v-auth="'wallet_walletRuleTransaction_transfer'"
|
||||
@click="doTransferAccounts(row)"
|
||||
>
|
||||
@click="doTransferAccounts(row)">
|
||||
转账
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle"
|
||||
v-bind="state.pagination" />
|
||||
</template>
|
||||
</DraggableDepTree>
|
||||
</div>
|
||||
<transaction-list ref="formDialogRef" @refresh="getDataList(false)" />
|
||||
<change-balance ref="changeBalanceRef" @refresh="getDataList(false)" />
|
||||
<transfer-accounts ref="transferAccountsRef" @refresh="getDataList(false)" />
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess"
|
||||
@failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,82 +1,44 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view">
|
||||
<DraggableDepTree
|
||||
ref="draggableDepTreeRef"
|
||||
placeholder="请输入部门名称"
|
||||
:show-expand="true"
|
||||
@getSelectNode="handleGetSelectNode"
|
||||
switch-inactive-text="包含"
|
||||
switch-active-text="不包含"
|
||||
:defaultTreeWidth="20"
|
||||
:nodeClickable="false"
|
||||
>
|
||||
<DraggableDepTree ref="draggableDepTreeRef" placeholder="请输入部门名称" :show-expand="true"
|
||||
@getSelectNode="handleGetSelectNode" switch-inactive-text="包含" switch-active-text="不包含"
|
||||
:defaultTreeWidth="20" :nodeClickable="false">
|
||||
<template #default>
|
||||
<SearchPanel
|
||||
v-show="showSearch"
|
||||
ref="queryRef"
|
||||
:model="state.queryForm"
|
||||
:loading="Boolean(state.loading)"
|
||||
:search-text="$t('common.queryBtn')"
|
||||
:reset-text="$t('common.resetBtn')"
|
||||
@search="getDataList"
|
||||
@reset="resetQuery"
|
||||
>
|
||||
<SearchPanel v-show="showSearch" ref="queryRef" :model="state.queryForm"
|
||||
:loading="Boolean(state.loading)" :search-text="$t('common.queryBtn')"
|
||||
:reset-text="$t('common.resetBtn')" @search="getDataList" @reset="resetQuery">
|
||||
<el-form-item style="width: 300px" label="用户" prop="userId">
|
||||
<select-user v-model="state.queryForm.userId" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 250px" label="钱包类型" prop="walletTypeIdList">
|
||||
<select-wallet-type multiple v-model="state.queryForm.walletTypeIdList" placeholder="饭卡类型" clearable />
|
||||
<select-wallet-type multiple v-model="state.queryForm.walletTypeIdList" placeholder="饭卡类型"
|
||||
clearable />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 200px" label="月份" prop="walletRecordMonth">
|
||||
<el-date-picker
|
||||
v-model="state.queryForm.walletRecordMonth"
|
||||
value-format="YYYY-MM"
|
||||
type="month"
|
||||
:clearable="false"
|
||||
placeholder="选择月份"
|
||||
/>
|
||||
<el-date-picker v-model="state.queryForm.walletRecordMonth" value-format="YYYY-MM"
|
||||
type="month" :clearable="false" placeholder="选择月份" />
|
||||
</el-form-item>
|
||||
</SearchPanel>
|
||||
<el-row>
|
||||
<div class="mb8 flex justify-between" style="width: 100%">
|
||||
<el-button
|
||||
plain
|
||||
:disabled="multiple"
|
||||
icon="Delete"
|
||||
type="primary"
|
||||
v-auth="'wallet_walletManage_del'"
|
||||
@click="handleDelete(selectObjs)"
|
||||
>
|
||||
<div class="mb8" style="width: 100%">
|
||||
<el-button plain :disabled="multiple" icon="Delete" type="primary"
|
||||
v-auth="'wallet_walletManage_del'" @click="handleDelete(selectObjs)">
|
||||
删除
|
||||
</el-button>
|
||||
<div class="flex gap-5">
|
||||
<right-toolbar
|
||||
v-model:showSearch="showSearch"
|
||||
:export="'wallet_walletRecord_export'"
|
||||
@exportExcel="exportExcel"
|
||||
class="ml10 mr20"
|
||||
style="float: right"
|
||||
@queryTable="getDataList"
|
||||
>
|
||||
<el-tooltip class="item" effect="dark" content="统计" placement="top">
|
||||
<el-button icon="DataLine" circle :loading="balanceStatisticsLoading" @click="viewBalanceStatistics" />
|
||||
</el-tooltip>
|
||||
</right-toolbar>
|
||||
</div>
|
||||
<right-toolbar v-model:showSearch="showSearch" :export="'wallet_walletRecord_export'"
|
||||
@exportExcel="exportExcel" class="ml10 mr20" style="float: right"
|
||||
@queryTable="getDataList">
|
||||
<el-tooltip class="item" effect="dark" content="统计" placement="top">
|
||||
<el-button icon="DataLine" circle :loading="balanceStatisticsLoading"
|
||||
@click="viewBalanceStatistics" />
|
||||
</el-tooltip>
|
||||
</right-toolbar>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-table
|
||||
:data="state.dataList"
|
||||
v-loading="state.loading"
|
||||
border
|
||||
:summary-method="getSummaries"
|
||||
show-summary
|
||||
:cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle"
|
||||
@selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle"
|
||||
>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :summary-method="getSummaries"
|
||||
show-summary :cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
@selection-change="selectionChangHandle" @sort-change="sortChangeHandle">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="username" width="120" label="用户" />
|
||||
<el-table-column prop="userAvatar" width="80" label="头像">
|
||||
@@ -115,18 +77,15 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle"
|
||||
v-bind="state.pagination" />
|
||||
</template>
|
||||
</DraggableDepTree>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="showDrawer" @close="drawerClose">
|
||||
<el-table
|
||||
v-loading="balanceStatisticsLoading"
|
||||
:data="balanceStatisticsLoaded ? [balanceStatistics] : []"
|
||||
:empty-text="balanceStatisticsLoading ? '统计中…' : '暂无统计数据'"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table v-loading="balanceStatisticsLoading" :data="balanceStatisticsLoaded ? [balanceStatistics] : []"
|
||||
:empty-text="balanceStatisticsLoading ? '统计中…' : '暂无统计数据'" style="width: 100%">
|
||||
<el-table-column align="center" label="余额统计">
|
||||
<el-table-column align="center" prop="total" label="总余额">
|
||||
<template #default="{ row }">
|
||||
@@ -151,7 +110,8 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess"
|
||||
@failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[
|
||||
{
|
||||
"fileName": "a.jpg",
|
||||
"appId": "mini",
|
||||
"kid": "v1",
|
||||
"ts": 1793232000,
|
||||
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"canonical": "v1\noss-file-read\nmini\nv1\n1793232000\nYS5qcGc",
|
||||
"sign": "348b1a5dd5d20560b7537916a6ec525efd173ccc1ee4af5357ca634f26296eb4"
|
||||
},
|
||||
{
|
||||
"fileName": "照片 +&%.png",
|
||||
"appId": "admin",
|
||||
"kid": "v1",
|
||||
"ts": 1793232000,
|
||||
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"canonical": "v1\noss-file-read\nadmin\nv1\n1793232000\n54Wn54mHICsmJS5wbmc",
|
||||
"sign": "9cdaa97a00bf333cf7af13cd915147d474367ef66c9a46adba27f2cbf910ea41"
|
||||
},
|
||||
{
|
||||
"fileName": "literal%2Fname.apk",
|
||||
"appId": "package",
|
||||
"kid": "v1",
|
||||
"ts": 1793232000,
|
||||
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"canonical": "v1\noss-file-read\npackage\nv1\n1793232000\nbGl0ZXJhbCUyRm5hbWUuYXBr",
|
||||
"sign": "a28e0b9ec5854749128b0413f0d97c425b8ee785c0c53b41f4469ee509c37e10"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"target": "upload",
|
||||
"appId": "admin",
|
||||
"kid": "v1",
|
||||
"ts": 1793232000,
|
||||
"nonce": "00112233445566778899aabbccddeeff",
|
||||
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"canonical": "v1\noss-file-upload\nPOST\nupload\nadmin\nv1\n1793232000\n00112233445566778899aabbccddeeff",
|
||||
"sign": "75fde91ab548250139f260680aeb2e51f831d29d8e4fb3cfb28898528fd3a2a8"
|
||||
},
|
||||
{
|
||||
"target": "qn-upload",
|
||||
"appId": "h5",
|
||||
"kid": "v1",
|
||||
"ts": 1793232000,
|
||||
"nonce": "ffeeddccbbaa99887766554433221100",
|
||||
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"canonical": "v1\noss-file-upload\nPOST\nqn-upload\nh5\nv1\n1793232000\nffeeddccbbaa99887766554433221100",
|
||||
"sign": "01cef848210b1bc31f5fe7679428b36d8e0f15b9f5517472a023d4d2c008086c"
|
||||
},
|
||||
{
|
||||
"target": "inner-upload",
|
||||
"appId": "service",
|
||||
"kid": "v1",
|
||||
"ts": 1793232000,
|
||||
"nonce": "0123456789abcdef0123456789abcdef",
|
||||
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
"canonical": "v1\noss-file-upload\nPOST\ninner-upload\nservice\nv1\n1793232000\n0123456789abcdef0123456789abcdef",
|
||||
"sign": "0d49bb992e5138cdb7a3f2bd7c83dfd60aedeae2b205898c4a6211c794f35aae"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
const { test } = require("node:test")
|
||||
const assert = require("node:assert/strict")
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
const ts = require("typescript")
|
||||
const vue = require("vue")
|
||||
const { parse, compileScript, compileTemplate } = require("@vue/compiler-sfc")
|
||||
const root = path.join(__dirname, "../src")
|
||||
|
||||
function load(source, mocks, globals = {}) {
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2021 },
|
||||
}).outputText
|
||||
const mod = { exports: {} }
|
||||
new Function("module", "exports", "require", ...Object.keys(globals), compiled)(mod, mod.exports,
|
||||
key => key in mocks ? mocks[key] : require(key), ...Object.values(globals))
|
||||
return mod.exports
|
||||
}
|
||||
|
||||
test("部门导出提交名称筛选快照,复用人员导出的进度和下载流程", async () => {
|
||||
const requests = []
|
||||
const progress = []
|
||||
const downloads = []
|
||||
const submitted = { taskId: "a".repeat(32), redisKey: "admin:export:dept", status: "PENDING" }
|
||||
const request = options => { requests.push(options); return Promise.resolve({ data: submitted }) }
|
||||
const tasks = load(fs.readFileSync(path.join(root, "api/asyncTask.ts"), "utf8"), {
|
||||
"/@/utils/request": { default: request },
|
||||
})
|
||||
const api = load(fs.readFileSync(path.join(root, "api/admin/dept.ts"), "utf8"), {
|
||||
"/@/utils/request": { default: request }, "/@/api/asyncTask": tasks,
|
||||
})
|
||||
const hook = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
|
||||
vue, "/@/components/AsyncTaskProgress.vue": {},
|
||||
"/@/hooks/message": { useMessage: () => ({ error: message => assert.fail(message) }) },
|
||||
"/@/utils/exportDownload": { downloadExportFile: (...args) => downloads.push(args) },
|
||||
})
|
||||
const descriptor = parse(fs.readFileSync(path.join(root, "views/admin/system/dept/index.vue"), "utf8")).descriptor
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: "dept.vue", id: "dept" }).errors, [])
|
||||
const page = load(compileScript(descriptor, { id: "dept" }).content, {
|
||||
vue, "/@/api/admin/dept": api,
|
||||
"/@/components/AsyncTaskProgress.vue": {}, "/@/hooks/batchExport": hook,
|
||||
"vue-i18n": { useI18n: () => ({ t: key => key }) },
|
||||
"/@/stores/userInfo": { useUserInfo: () => ({ userInfos: { roleCodes: [] } }) },
|
||||
}, { ref: vue.ref, reactive: vue.reactive, defineAsyncComponent: vue.defineAsyncComponent })
|
||||
const state = page.default.setup({}, { expose() {} })
|
||||
state.exportProgressRef.value = {
|
||||
startSubmitting: name => progress.push(name), start: value => progress.push(value),
|
||||
show: () => progress.push("show"), submissionFailed: message => assert.fail(message),
|
||||
}
|
||||
state.state.queryForm.deptName = "人事"
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].url, "/admin/dept/export-async")
|
||||
assert.equal(requests[0].method, "post")
|
||||
assert.deepEqual(requests[0].data, { deptName: "人事" })
|
||||
assert.equal(state.state.queryForm.deptName, "人事")
|
||||
assert.equal(state.exportTaskConfig.progressUrl, "/admin/async-task/progress")
|
||||
assert.equal(state.exportTaskConfig.redisKey, "admin:export:dept")
|
||||
assert.deepEqual(progress, ["部门管理", submitted])
|
||||
state.state.queryForm.deptName = "财务"
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].data.deptName, "人事")
|
||||
assert.equal(progress.at(-1), "show")
|
||||
state.handleExportSuccess({ resultFileUrl: "/admin/sys-file/oss/file?fileName=depts.xlsx", resultFileName: "部门管理.xlsx" })
|
||||
assert.deepEqual(downloads, [["/admin/sys-file/oss/file?fileName=depts.xlsx", "部门管理.xlsx"]])
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const Module = require('node:module');
|
||||
const ts = require('typescript');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const corePath = fs.existsSync(path.join(root, 'src/utils/file-access-core.ts'))
|
||||
? path.join(root, 'src/utils/file-access-core.ts') : path.join(root, 'src/sheep/utils/file-access-core.ts');
|
||||
const loaded = new Module(corePath, module);
|
||||
loaded.filename = corePath; loaded.paths = Module._nodeModulePaths(path.dirname(corePath));
|
||||
loaded._compile(ts.transpileModule(fs.readFileSync(corePath, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText, corePath);
|
||||
const { fileAccessSignature, fileAccessCanonical, createFileAccess, rewriteFileHtml, rewriteFileNodes } = loaded.exports;
|
||||
const vectors = JSON.parse(fs.readFileSync(path.join(root, 'test-vectors/file-access.json'), 'utf8'));
|
||||
for (const v of vectors) test('cross-language vector: ' + v.fileName, () => {
|
||||
assert.equal(fileAccessCanonical(v.fileName,v.appId,v.kid,v.ts),v.canonical);
|
||||
assert.equal(fileAccessSignature(v.fileName,v.appId,v.kid,v.ts,v.key),v.sign);
|
||||
});
|
||||
let time;
|
||||
function client(baseUrl='https://files.example/api') {
|
||||
time = 1793232000000;
|
||||
return createFileAccess({appId:'mini',kid:'v1',key:vectors[0].key,baseUrl:()=>baseUrl,now:()=>time,allowedOrigins:['https://legacy.example']});
|
||||
}
|
||||
test('prefixes, absolute URLs, and bare names address the same file',()=>{
|
||||
const c=client();
|
||||
for(const value of ['/admin/sys-file/oss/file?fileName=a.jpg','/api/admin/sys-file/oss/file?fileName=a.jpg','https://files.example/api/admin/sys-file/oss/file?fileName=a.jpg','a.jpg']) {
|
||||
const url=c.url(value); assert.match(url,/^https:\/\/files.example\/api\/admin\/sys-file\/oss\/file\?/);
|
||||
assert.equal(new URL(url).searchParams.get('sign'),vectors[0].sign);
|
||||
assert.ok(url.endsWith('fileName=a.jpg'));
|
||||
}
|
||||
});
|
||||
test('relative API base, no duplicate prefixes',()=>{
|
||||
const c=client('/api'); assert.match(c.url('/api/admin/sys-file/oss/file?fileName=a.jpg'),/^\/api\/admin/);
|
||||
});
|
||||
test('foreign URLs and local resources never receive credentials',()=>{
|
||||
const c=client();
|
||||
for(const value of ['https://other.example/admin/sys-file/oss/file?fileName=a.jpg','https://files.example.evil/api/admin/sys-file/oss/file?fileName=a.jpg','data:image/png;base64,AA==','blob:https://files.example/abc','/static/logo.png','https://files.example/qiniu/a.jpg']) assert.equal(c.url(value),value);
|
||||
});
|
||||
test('one decode, unusual names, no duplicated signature and cache renewal',()=>{
|
||||
const c=client(), name='照片 +&%.png', source='/admin/sys-file/oss/file?fileName='+encodeURIComponent(name);
|
||||
const first=c.url(source), params=new URL(first).searchParams;
|
||||
assert.equal(params.get('fileName'),name); assert.equal(params.get('sign'),fileAccessSignature(name,'mini','v1',1793232000,vectors[0].key));
|
||||
assert.equal(c.url(first),first); time+=10*60000; assert.equal(c.url(source),first);
|
||||
time+=16*60000; const renewed=c.url(source); assert.notEqual(renewed,first);
|
||||
assert.equal(c.strip(first),c.strip(renewed)); assert.equal(c.strip(source),source);
|
||||
});
|
||||
test('reject ambiguous file names and never silently use an empty key',()=>{
|
||||
const c=client(); assert.throws(()=>c.url('/admin/sys-file/oss/file?fileName=a&fileName=b'));
|
||||
assert.throws(()=>c.url('/admin/sys-file/oss/file?fileName='));
|
||||
assert.throws(()=>fileAccessSignature('a','mini','v1',1,''));
|
||||
});
|
||||
test('clock correction renews a skewed signature',()=>{
|
||||
const c=client();time+=86400000;c.synchronize(1793232000000,1800,0);
|
||||
assert.equal(new URL(c.url('a.jpg')).searchParams.get('ts'),'1793232000');
|
||||
});
|
||||
test('routine clock synchronization preserves usable signatures and corrects large clock changes', () => {
|
||||
const c = client(), first = c.url('a.jpg');
|
||||
time += 2000; c.synchronize(time + 4000, 1800, 0);
|
||||
assert.equal(c.url('a.jpg'), first);
|
||||
c.synchronize(time + 86400000, 1800, 0);
|
||||
assert.notEqual(c.url('a.jpg'), first);
|
||||
});
|
||||
test('HTML and rich-text nodes sign for presentation without mutating business values',()=>{
|
||||
const c=client(), html='<p>hello</p><img src="/api/admin/sys-file/oss/file?fileName=a.jpg"><a href="https://other.example/a">x</a>';
|
||||
const transform=s=>c.protected(s)?c.url(s):s;
|
||||
const rendered=rewriteFileHtml(html,transform);assert.match(rendered,/&sign=/);assert.match(rendered,/href="https:\/\/other.example\/a"/);
|
||||
const saved=rewriteFileHtml(rendered,s=>c.strip(s));assert.doesNotMatch(saved,/[?&](?:amp;)?(?:sign|ts|kid|appId)=/);assert.match(saved,/fileName=a.jpg/);
|
||||
const nodes=[{name:'img',attrs:{src:'/api/admin/sys-file/oss/file?fileName=a.jpg'}}], before=JSON.stringify(nodes);
|
||||
assert.match(rewriteFileNodes(nodes,transform)[0].attrs.src,/sign=/);assert.equal(JSON.stringify(nodes),before);
|
||||
});
|
||||
|
||||
test('configured deployment prefixes and encoded parameter names remain unambiguous', () => {
|
||||
const c = client('https://files.example/gateway/api');
|
||||
const relative = '/gateway/api/admin/sys-file/oss/file?%66ileName=a.jpg';
|
||||
const url = c.url(relative);
|
||||
assert.equal(new URL(url).pathname, '/gateway/api/admin/sys-file/oss/file');
|
||||
assert.equal(new URL(url).searchParams.get('sign'), vectors[0].sign);
|
||||
assert.equal(c.url(url), url);
|
||||
assert.throws(() => c.url(relative + '&fileName=b.jpg'));
|
||||
const special = c.url('/admin/sys-file/oss/file?fileName=a%252Bb.png');
|
||||
assert.equal(new URL(special).searchParams.get('fileName'), 'a%2Bb.png');
|
||||
});
|
||||
|
||||
test('real runtime wrapper initializes, calibrates without login, and renews reactively', async () => {
|
||||
const { computed } = require('vue');
|
||||
const filename = path.join(path.dirname(corePath), 'file-access.ts');
|
||||
const env = {
|
||||
VITE_FILE_ACCESS_APP_ID: 'mini', VITE_FILE_ACCESS_KEY_ID: 'v1', VITE_FILE_ACCESS_KEY: vectors[0].key,
|
||||
VITE_IMAGE_BASE_PATH: 'https://files.example/api', VITE_IMAGE_DEV_PATH: 'https://files.example/api',
|
||||
VITE_APP_DEV_SERVICE: 'https://files.example/api', VITE_APP_PROD_IMAGE: 'https://files.example/api',
|
||||
VITE_APP_PROD_SERVICE: 'https://files.example/api', VITE_FILE_ACCESS_ALLOWED_ORIGINS: 'https://legacy.example',
|
||||
};
|
||||
const runtime = new Module(filename, module); runtime.filename = filename; runtime.paths = loaded.paths;
|
||||
runtime.require = (name) => name === './file-access-core' ? loaded.exports : name === './app' ? { isDevEnv: () => true } : loaded.require(name);
|
||||
const source = fs.readFileSync(filename, 'utf8').replace(/import\.meta\.env/g, '(' + JSON.stringify(env) + ')');
|
||||
const originalFetch = global.fetch, originalUni = global.uni, realNow = Date.now;
|
||||
const serverTime = realNow() + 86400000;
|
||||
const info = { data: { serverTime, ttlSeconds: 1800 } };
|
||||
global.fetch = async (url, options) => {
|
||||
assert.equal(url, 'https://files.example/api/admin/sys-file/oss/access-info');
|
||||
assert.equal(options.credentials, 'omit'); return { ok: true, json: async () => info };
|
||||
};
|
||||
global.uni = { request: options => { assert.match(options.url, /oss\/access-info$/); options.success({ statusCode: 200, data: info }); } };
|
||||
let elapsed = 0;
|
||||
Date.now = () => realNow() + elapsed;
|
||||
try {
|
||||
runtime._compile(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText, filename);
|
||||
const api = runtime.exports, raw = '/admin/sys-file/oss/file?fileName=a.jpg';
|
||||
const shown = computed(() => api.accessFileUrl(raw));
|
||||
const first = shown.value;
|
||||
assert.match(first, /sign=/);
|
||||
assert.match(api.accessFileUrl('https://legacy.example/api/admin/sys-file/oss/file?fileName=a.jpg'), /sign=/);
|
||||
await api.refreshFileAccess(true);
|
||||
assert.equal(Number(new URL(shown.value).searchParams.get('ts')), Math.floor(serverTime / 1000));
|
||||
const calibrated = shown.value;
|
||||
elapsed = 26 * 60000;
|
||||
api.fileAccessRevision.value++;
|
||||
assert.notEqual(shown.value, calibrated);
|
||||
assert.equal(api.originalFileUrl(shown.value), api.originalFileUrl(first));
|
||||
assert.equal(raw, '/admin/sys-file/oss/file?fileName=a.jpg');
|
||||
assert.equal(api.accessFileUrl('/static/logo.png'), '/static/logo.png');
|
||||
assert.equal(api.accessFileUrl('blob:https://files.example/test'), 'blob:https://files.example/test');
|
||||
} finally { global.fetch = originalFetch; global.uni = originalUni; Date.now = realNow; }
|
||||
});
|
||||
|
||||
test('frontend proxy /api is normalized to the configured file-server prefix', () => {
|
||||
const c = client('https://files.example');
|
||||
assert.equal(new URL(c.url('/api/admin/sys-file/oss/file?fileName=a.jpg')).pathname, '/admin/sys-file/oss/file');
|
||||
assert.equal(c.resolve('/api/admin/sys-file/oss/access-info'), 'https://files.example/admin/sys-file/oss/access-info');
|
||||
assert.equal(new URL(c.url('https://legacy.example/api/admin/sys-file/oss/file?fileName=a.jpg')).pathname, '/api/admin/sys-file/oss/file');
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vue = require('vue')
|
||||
const { parse, compileScript } = require('@vue/compiler-sfc')
|
||||
const { runtime, compile, root } = require('./helpers/file-access-runtime.cjs')
|
||||
|
||||
const raw = file => '/admin/sys-file/oss/file?fileName=' + file
|
||||
const settle = async () => { await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() }
|
||||
|
||||
function mountImages(api) {
|
||||
const descriptor = parse(fs.readFileSync(path.join(root, 'src/components/FileImage/index.vue'), 'utf8')).descriptor
|
||||
const component = compileScript(descriptor, { id: 'file-image-regression', inlineTemplate: true }).content
|
||||
const loaded = { exports: {} }
|
||||
const loads = [], failures = []
|
||||
// Element Plus restarts loading when its src prop changes. Record that boundary
|
||||
// while executing the real wrapper's setup, template, events and Vue lifecycle.
|
||||
const ElImage = vue.defineComponent({
|
||||
props: ['src', 'previewSrcList'], emits: ['load', 'error', 'show', 'switch', 'close'],
|
||||
setup(props, { emit }) {
|
||||
vue.watch(() => props.src, value => loads.push(value), { immediate: true })
|
||||
return () => vue.h('img', {
|
||||
src: props.src, preview: props.previewSrcList,
|
||||
onLoad: event => emit('load', event), onError: event => emit('error', event),
|
||||
onShow: () => emit('show'), onSwitch: index => emit('switch', index),
|
||||
})
|
||||
},
|
||||
})
|
||||
new Function('module', 'exports', 'require', compile(component))(loaded, loaded.exports, id => {
|
||||
if (id === 'element-plus') return { ElImage }
|
||||
if (id === '/@/utils/file-access') return api
|
||||
return require(id)
|
||||
})
|
||||
const renderer = vue.createRenderer({
|
||||
createElement: type => ({ type, props: {}, children: [] }), createText: text => ({ text }), createComment: text => ({ text }),
|
||||
setText: (node, text) => { node.text = text }, setElementText: (node, text) => { node.text = text },
|
||||
parentNode: node => node.parent, nextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1],
|
||||
patchProp: (node, key, previous, next) => { node.props[key] = next },
|
||||
insert(node, parent, anchor) {
|
||||
if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1)
|
||||
node.parent = parent
|
||||
const index = anchor ? parent.children.indexOf(anchor) : -1
|
||||
parent.children.splice(index < 0 ? parent.children.length : index, 0, node)
|
||||
},
|
||||
remove(node) { if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1) },
|
||||
})
|
||||
const sources = vue.ref([raw('good.jpg'), raw('other.jpg')])
|
||||
const container = { children: [] }
|
||||
const app = renderer.createApp({
|
||||
setup() {
|
||||
return () => vue.h('section', sources.value.map((source, index) => vue.h(loaded.exports.default, {
|
||||
key: index, src: api.accessFileUrl(source), previewSrcList: api.fileUrls([source]),
|
||||
onError: () => failures.push(index),
|
||||
})))
|
||||
},
|
||||
})
|
||||
app.mount(container)
|
||||
return { loads, failures, sources, images: () => container.children[0].children, close: () => app.unmount() }
|
||||
}
|
||||
|
||||
test('one failed image renews only itself, coalesces concurrent retries, and remains bounded', async () => {
|
||||
const env = runtime(), { api, clock } = env
|
||||
try {
|
||||
const good = api.fileUrl(raw('good.jpg')), bad = api.fileUrl(raw('missing.jpg'))
|
||||
clock.now += 1000
|
||||
assert.deepEqual(await Promise.all([api.retryFileAccess(raw('missing.jpg')), api.retryFileAccess(raw('missing.jpg'))]), [true, true])
|
||||
assert.equal(clock.requests, 1)
|
||||
assert.equal(api.fileUrl(raw('good.jpg')), good)
|
||||
assert.notEqual(api.fileUrl(raw('missing.jpg')), bad)
|
||||
assert.equal(await api.retryFileAccess(raw('missing.jpg')), false)
|
||||
assert.equal(clock.requests, 1)
|
||||
const offline = api.fileUrl(raw('offline.jpg'))
|
||||
clock.offline = true; clock.now += 1000
|
||||
assert.equal(await api.retryFileAccess(raw('offline.jpg')), true)
|
||||
assert.notEqual(api.fileUrl(raw('offline.jpg')), offline)
|
||||
assert.equal(api.fileUrl(raw('good.jpg')), good)
|
||||
} finally { env.close() }
|
||||
})
|
||||
|
||||
test('owned anchors fetch through the app downloader, while external and already handled links keep their behaviour', () => {
|
||||
const env = runtime(), calls = []
|
||||
try {
|
||||
const event = (href, prevented = false) => ({
|
||||
defaultPrevented: prevented,
|
||||
target: { closest: () => ({ getAttribute: name => name === 'href' ? href : '' }) },
|
||||
preventDefault() { this.defaultPrevented = true }, stopImmediatePropagation() { this.stopped = true },
|
||||
})
|
||||
const source = raw(encodeURIComponent('附件 +&%.pdf')), signed = env.api.fileUrl(source)
|
||||
const click = event(signed)
|
||||
assert.equal(env.api.handleFileLinkClick(click, (...args) => calls.push(args)), true)
|
||||
assert.equal(click.defaultPrevented, true); assert.equal(click.stopped, true)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0][2], '附件 +&%.pdf')
|
||||
assert.doesNotMatch(calls[0][0], /[?&](?:sign|ts|kid|appId)=/)
|
||||
for (const href of ['https://other.example/file.pdf', 'blob:local-document', '/home']) {
|
||||
const outside = event(href)
|
||||
assert.equal(env.api.handleFileLinkClick(outside, () => assert.fail('unrelated link intercepted')), false)
|
||||
assert.equal(outside.defaultPrevented, false)
|
||||
}
|
||||
assert.equal(env.api.handleFileLinkClick(event(signed, true), () => assert.fail('already handled')), false)
|
||||
} finally { env.close() }
|
||||
})
|
||||
|
||||
test('loaded thumbnails never reload on minute checks, expiry, or foreground clock sync; previews receive fresh signatures', async () => {
|
||||
const env = runtime(), page = mountImages(env.api)
|
||||
try {
|
||||
page.images().forEach(image => image.props.onLoad(new Event('load')))
|
||||
const before = page.images().map(image => image.props.src)
|
||||
for (let minute = 0; minute < 31; minute++) {
|
||||
env.clock.now += 60000
|
||||
env.api.fileAccessRevision.value++
|
||||
await vue.nextTick()
|
||||
assert.deepEqual(page.images().map(image => image.props.src), before)
|
||||
}
|
||||
await env.api.refreshFileAccess(true); await settle()
|
||||
assert.equal(page.loads.length, 2)
|
||||
assert.deepEqual(page.images().map(image => image.props.src), before)
|
||||
page.images()[0].props.onShow(); await settle()
|
||||
const preview = page.images()[0].props.preview[0]
|
||||
assert.notEqual(preview, before[0])
|
||||
assert.equal(preview, env.api.fileUrl(raw('good.jpg')))
|
||||
assert.ok(Number(new URL(preview).searchParams.get('ts')) + 1800 > env.clock.now / 1000)
|
||||
assert.equal(page.loads.length, 2)
|
||||
assert.equal(page.sources.value[0], raw('good.jpg'))
|
||||
env.clock.now += 31 * 60000; env.api.fileAccessRevision.value++; await settle()
|
||||
assert.equal(page.images()[0].props.preview[0], preview)
|
||||
page.images()[0].props.onSwitch(0); await settle()
|
||||
assert.notEqual(page.images()[0].props.preview[0], preview)
|
||||
} finally { page.close(); env.close() }
|
||||
})
|
||||
|
||||
test('failed thumbnails retry once without resetting neighbours; a changed business image still loads', async () => {
|
||||
const env = runtime(), page = mountImages(env.api)
|
||||
try {
|
||||
page.images()[1].props.onLoad(new Event('load'))
|
||||
const neighbour = page.images()[1].props.src
|
||||
env.clock.now += 1000
|
||||
page.images()[0].props.onError(new Event('error')); await settle()
|
||||
assert.equal(page.loads.length, 3)
|
||||
assert.equal(page.images()[1].props.src, neighbour)
|
||||
page.images()[0].props.onError(new Event('error')); await settle()
|
||||
assert.equal(page.loads.length, 3)
|
||||
assert.deepEqual(page.failures, [0])
|
||||
env.api.fileAccessRevision.value++; await settle()
|
||||
assert.equal(page.loads.length, 3)
|
||||
page.sources.value[0] = raw('replacement.jpg'); await settle()
|
||||
assert.equal(new URL(page.images()[0].props.src).searchParams.get('fileName'), 'replacement.jpg')
|
||||
assert.equal(page.images()[1].props.src, neighbour)
|
||||
} finally { page.close(); env.close() }
|
||||
})
|
||||
|
||||
test('offscreen images keep a valid URL for their first lazy request without reloading an already visible image', async () => {
|
||||
const env = runtime(), page = mountImages(env.api)
|
||||
try {
|
||||
page.images()[1].props.onLoad(new Event('load'))
|
||||
const before = page.images().map(image => image.props.src)
|
||||
env.clock.now += 31 * 60000; env.api.fileAccessRevision.value++; await settle()
|
||||
assert.notEqual(page.images()[0].props.src, before[0])
|
||||
assert.equal(new URL(page.images()[0].props.src).searchParams.get('ts'), String(env.clock.now / 1000))
|
||||
assert.equal(page.images()[1].props.src, before[1])
|
||||
assert.equal(env.clock.requests, 0)
|
||||
} finally { page.close(); env.close() }
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
const {test} = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const Module = require('node:module')
|
||||
const ts = require('typescript')
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const folder = fs.existsSync(path.join(root,'src/utils/file-access-core.ts')) ? 'src/utils' : 'src/sheep/utils'
|
||||
const corePath = path.join(root,folder,'file-access-core.ts')
|
||||
const compile = source => ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText
|
||||
const core = new Module(corePath,module); core.filename=corePath; core.paths=Module._nodeModulePaths(path.dirname(corePath))
|
||||
core._compile(compile(fs.readFileSync(corePath,'utf8')),corePath)
|
||||
const vectors=JSON.parse(fs.readFileSync(path.join(root,'test-vectors/file-upload.json'),'utf8'))
|
||||
for(const v of vectors) test('upload signature vector: '+v.target,()=>{
|
||||
assert.equal(core.exports.fileUploadCanonical(v.target,v.appId,v.kid,v.ts,v.nonce),v.canonical)
|
||||
assert.equal(core.exports.fileUploadSignature(v.target,v.appId,v.kid,v.ts,v.nonce,v.key),v.sign)
|
||||
})
|
||||
test('upload credentials bind the purpose and target, renew per request and never leak to other hosts or imports',()=>{
|
||||
let time=1793232000000
|
||||
const client=core.exports.createFileAccess({appId:'admin',kid:'v1',key:vectors[0].key,baseUrl:()=> 'https://files.example/gateway/api',now:()=>time,allowedOrigins:['https://legacy.example']})
|
||||
for(const [source,target] of [['/api/admin/sys-file/upload','upload'],['/sys-file/inner-upload','inner-upload'],['https://legacy.example/admin/sys-file/qn/upload','qn-upload'],['https://files.example/gateway/api/admin/sys-file/upload','upload']]){
|
||||
const headers=client.uploadHeaders(source,vectors[0].nonce)
|
||||
assert.equal(headers['X-File-Signature'],core.exports.fileUploadSignature(target,'admin','v1',1793232000,vectors[0].nonce,vectors[0].key))
|
||||
assert.equal(Object.values(headers).includes(vectors[0].key),false)
|
||||
}
|
||||
const first=client.uploadHeaders('/admin/sys-file/upload'), second=client.uploadHeaders('/admin/sys-file/upload')
|
||||
assert.notEqual(first['X-File-Nonce'],second['X-File-Nonce'])
|
||||
assert.notEqual(first['X-File-Signature'],second['X-File-Signature'])
|
||||
assert.equal(first['X-File-Timestamp'],'1793232000')
|
||||
client.synchronize(time+86400000,1800,0)
|
||||
assert.equal(client.uploadHeaders('/admin/sys-file/upload')['X-File-Timestamp'],'1793318400')
|
||||
for(const source of ['https://outside.example/admin/sys-file/upload','https://files.example.evil/admin/sys-file/upload','/admin/sys-file/oss/file?fileName=x','/admin/user/import','data:upload','blob:upload']){
|
||||
assert.deepEqual(client.uploadHeaders(source),{})
|
||||
}
|
||||
assert.notEqual(core.exports.fileAccessSignature('a.jpg','admin','v1',1793232000,vectors[0].key),first['X-File-Signature'])
|
||||
})
|
||||
function runtime(){
|
||||
const filename=path.join(root,folder,'file-access.ts')
|
||||
const env={VITE_FILE_ACCESS_APP_ID:'admin',VITE_FILE_ACCESS_KEY_ID:'v1',VITE_FILE_ACCESS_KEY:vectors[0].key,VITE_IMAGE_BASE_PATH:'https://files.example/api',VITE_IMAGE_DEV_PATH:'https://files.example/api',VITE_APP_DEV_SERVICE:'https://files.example/api',VITE_APP_PROD_IMAGE:'https://files.example/api',VITE_APP_PROD_SERVICE:'https://files.example/api'}
|
||||
const loaded=new Module(filename,module);loaded.filename=filename;loaded.paths=core.paths
|
||||
loaded.require=name=>name==='./file-access-core'?core.exports:name==='./app'?{isDevEnv:()=>true}:core.require(name)
|
||||
loaded._compile(compile(fs.readFileSync(filename,'utf8').replace(/import\.meta\.env/g,'('+JSON.stringify(env)+')')),filename)
|
||||
return loaded.exports
|
||||
}
|
||||
if(folder==='src/utils') test('the real Axios interceptor signs each file upload and preserves login and tenant headers',()=>{
|
||||
const api=runtime();let onRequest
|
||||
const axios={getUri:require('axios').getUri,create:()=>({interceptors:{request:{use(fn){onRequest=fn}},response:{use(){}}}})}
|
||||
const filename=path.join(root,'src/utils/request.ts');const loaded=new Module(filename,module);loaded.filename=filename;loaded.paths=core.paths
|
||||
loaded.require=name=>{
|
||||
if(name==='axios')return {default:axios}
|
||||
if(name==='./file-access')return api
|
||||
if(name==='./other')return {default:{adaptationUrl:url=>url}}
|
||||
if(name==='/@/utils/storage')return {Session:{getToken:()=> 'user-token',getTenant:()=> '42'}}
|
||||
if(name==='/@/hooks/message')return {}
|
||||
return core.require(name)
|
||||
}
|
||||
loaded._compile(compile(fs.readFileSync(filename,'utf8').replace(/import\.meta\.env/g,'({VITE_API_URL:"/api"})')),filename)
|
||||
const first=onRequest({method:'post',url:'/admin/sys-file/upload',headers:{},data:{file:'unchanged'}})
|
||||
const second=onRequest({method:'post',url:'/admin/sys-file/upload',headers:{},data:{file:'unchanged'}})
|
||||
assert.equal(first.headers.Authorization,'Bearer user-token');assert.equal(first.headers['TENANT-ID'],'42')
|
||||
assert.ok(first.headers['X-File-Signature']);assert.notEqual(first.headers['X-File-Nonce'],second.headers['X-File-Nonce'])
|
||||
assert.deepEqual(first.data,{file:'unchanged'})
|
||||
const other=onRequest({method:'post',url:'https://outside.example/admin/sys-file/upload',headers:{}})
|
||||
assert.equal(other.headers['X-File-Signature'],undefined)
|
||||
const otherBase=onRequest({method:'post',baseURL:'https://outside.example/api',url:'/admin/sys-file/upload',headers:{}})
|
||||
assert.equal(otherBase.headers['X-File-Signature'],undefined)
|
||||
})
|
||||
else test('the actual uni upload interceptor signs photo, video and rich text calls without changing form data',()=>{
|
||||
const previous=global.uni;let interceptor
|
||||
global.uni={addInterceptor(name,hook){assert.equal(name,'uploadFile');interceptor=hook},onNetworkStatusChange(){}}
|
||||
try{
|
||||
const api=runtime();api.installFileAccess({config:{globalProperties:{}},mixin(){}})
|
||||
const first={url:'/api/admin/sys-file/upload',header:{Authorization:'Bearer user','TENANT-ID':'42'},formData:{dir:'photos'},filePath:'local-photo.png'}
|
||||
const second={...first,header:{...first.header}}
|
||||
interceptor.invoke(first);interceptor.invoke(second)
|
||||
assert.ok(first.header['X-File-Signature']);assert.notEqual(first.header['X-File-Nonce'],second.header['X-File-Nonce'])
|
||||
assert.equal(first.header.Authorization,'Bearer user');assert.equal(first.header['TENANT-ID'],'42')
|
||||
assert.deepEqual(first.formData,{dir:'photos'});assert.equal(first.filePath,'local-photo.png')
|
||||
const other={url:'https://outside.example/upload',header:{Authorization:'other'}};interceptor.invoke(other)
|
||||
assert.deepEqual(other.header,{Authorization:'other'})
|
||||
}finally{global.uni=previous}
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const Module = require('node:module')
|
||||
const ts = require('typescript')
|
||||
|
||||
const root = path.resolve(__dirname, '../..')
|
||||
const compile = source => ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText
|
||||
|
||||
function runtime() {
|
||||
const clock = { now: 1793232000000, requests: 0, offline: false }
|
||||
const realNow = Date.now, realFetch = global.fetch
|
||||
Date.now = () => clock.now
|
||||
global.fetch = async () => {
|
||||
clock.requests++
|
||||
if (clock.offline) throw new Error('Offline')
|
||||
return { ok: true, json: async () => ({ data: { serverTime: clock.now, ttlSeconds: 1800 } }) }
|
||||
}
|
||||
const filename = path.join(root, 'src/utils/file-access.ts')
|
||||
const core = new Module(path.join(root, 'src/utils/file-access-core.ts'), module)
|
||||
core.filename = core.id; core.paths = Module._nodeModulePaths(path.dirname(core.filename))
|
||||
core._compile(compile(fs.readFileSync(core.filename, 'utf8')), core.filename)
|
||||
const loaded = new Module(filename, module)
|
||||
loaded.filename = filename; loaded.paths = core.paths
|
||||
loaded.require = name => name === './file-access-core' ? core.exports : core.require(name)
|
||||
const env = {
|
||||
VITE_FILE_ACCESS_APP_ID: 'admin', VITE_FILE_ACCESS_KEY_ID: 'v1',
|
||||
VITE_FILE_ACCESS_KEY: JSON.parse(fs.readFileSync(path.join(root, 'test-vectors/file-access.json'), 'utf8'))[0].key,
|
||||
VITE_IMAGE_BASE_PATH: 'https://files.example/api', VITE_FILE_ACCESS_ALLOWED_ORIGINS: '',
|
||||
}
|
||||
loaded._compile(compile(fs.readFileSync(filename, 'utf8').replace(/import\.meta\.env/g, '(' + JSON.stringify(env) + ')')), filename)
|
||||
return { api: loaded.exports, clock, close() { Date.now = realNow; global.fetch = realFetch } }
|
||||
}
|
||||
|
||||
module.exports = { runtime, compile, root }
|
||||
@@ -0,0 +1,145 @@
|
||||
const { test } = require("node:test")
|
||||
const assert = require("node:assert/strict")
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
const ts = require("typescript")
|
||||
const vue = require("vue")
|
||||
const { parse, compileScript, compileTemplate } = require("@vue/compiler-sfc")
|
||||
const root = path.join(__dirname, "../src")
|
||||
|
||||
function load(source, mocks, globals = {}) {
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2021 },
|
||||
}).outputText
|
||||
const mod = { exports: {} }
|
||||
new Function("module", "exports", "require", ...Object.keys(globals), compiled)(mod, mod.exports,
|
||||
key => key in mocks ? mocks[key] : require(key), ...Object.values(globals))
|
||||
return mod.exports
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const requests = [], progress = [], downloads = [], errors = []
|
||||
const submitted = { taskId: "a".repeat(32), redisKey: "admin:export:log", status: "PENDING" }
|
||||
const request = options => { requests.push(options); return Promise.resolve({ data: submitted }) }
|
||||
const tasks = load(fs.readFileSync(path.join(root, "api/asyncTask.ts"), "utf8"), {
|
||||
"/@/utils/request": { default: request },
|
||||
})
|
||||
const api = load(fs.readFileSync(path.join(root, "api/admin/log.ts"), "utf8"), {
|
||||
"/@/utils/request": { default: request }, "/@/api/asyncTask": tasks,
|
||||
})
|
||||
const messages = { useMessage: () => ({ error: message => errors.push(message) }) }
|
||||
const hook = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
|
||||
vue, "/@/components/AsyncTaskProgress.vue": {}, "/@/hooks/message": messages,
|
||||
"/@/utils/exportDownload": { downloadExportFile: (...args) => downloads.push(args) },
|
||||
})
|
||||
const descriptor = parse(fs.readFileSync(path.join(root, "views/admin/log/index.vue"), "utf8")).descriptor
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: "log.vue", id: "log" }).errors, [])
|
||||
const page = load(compileScript(descriptor, { id: "log" }).content, {
|
||||
vue, "/@/api/admin/log": api,
|
||||
dayjs: { default: require("dayjs") },
|
||||
"dayjs/plugin/customParseFormat": { default: require("dayjs/plugin/customParseFormat") },
|
||||
"/@/components/AsyncTaskProgress.vue": {}, "/@/hooks/batchExport": hook,
|
||||
"vue-i18n": { useI18n: () => ({ t: key => key }) },
|
||||
"/@/hooks/table": { useTable: () => ({ getDataList() {}, tableStyle: {} }) },
|
||||
"/@/hooks/message": messages, "/@/hooks/dict": { useDict: () => ({ log_type: [] }) },
|
||||
}, {
|
||||
ref: vue.ref, reactive: vue.reactive, defineAsyncComponent: vue.defineAsyncComponent,
|
||||
onMounted() {}, useRoute: () => ({ query: {} }),
|
||||
})
|
||||
const state = page.default.setup({}, { expose() {} })
|
||||
state.exportProgressRef.value = {
|
||||
startSubmitting: name => progress.push(name), start: value => progress.push(value),
|
||||
show: () => progress.push("show"), submissionFailed: message => assert.fail(message),
|
||||
}
|
||||
return { state, requests, progress, downloads, errors, submitted }
|
||||
}
|
||||
|
||||
test("日志异步导出固定类型、时间和应用筛选,重复点击继续查看进度,成功后下载", async () => {
|
||||
const { state, requests, progress, downloads, submitted } = setup()
|
||||
Object.assign(state.state.queryForm, {
|
||||
logType: "9", serviceId: "sms", current: 3, size: 10,
|
||||
createTime: ["2026-10-29 00:00:00", "2026-10-29 23:59:59"],
|
||||
})
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].url, "/admin/log/export-async")
|
||||
assert.equal(requests[0].method, "post")
|
||||
assert.deepEqual(requests[0].data, {
|
||||
logType: "9", serviceId: "sms", createTime: ["2026-10-29 00:00:00", "2026-10-29 23:59:59"],
|
||||
})
|
||||
assert.equal(state.state.queryForm.current, 3)
|
||||
assert.equal(state.state.queryForm.size, 10)
|
||||
assert.equal(state.exportTaskConfig.progressUrl, "/admin/async-task/progress")
|
||||
assert.equal(state.exportTaskConfig.redisKey, "admin:export:log")
|
||||
assert.deepEqual(progress, ["操作日志", submitted])
|
||||
state.state.queryForm.createTime[0] = "2026-10-28 00:00:00"
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].data.createTime[0], "2026-10-29 00:00:00")
|
||||
assert.equal(progress.at(-1), "show")
|
||||
state.handleExportSuccess({ resultFileUrl: "/admin/sys-file/oss/file?fileName=logs.xlsx", resultFileName: "操作日志.xlsx" })
|
||||
assert.deepEqual(downloads, [["/admin/sys-file/oss/file?fileName=logs.xlsx", "操作日志.xlsx"]])
|
||||
})
|
||||
|
||||
test("未选择、清空或只选择一端时间时提示补全,不提交导出任务", async () => {
|
||||
for (const range of ["", null, [], [null, null], ["2026-10-29 00:00:00"], ["2026-10-29 00:00:00", null]]) {
|
||||
const { state, requests, errors, progress } = setup()
|
||||
state.state.queryForm.createTime = range
|
||||
state.showSearch.value = false
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 0)
|
||||
assert.deepEqual(progress, [])
|
||||
assert.deepEqual(errors, ["导出前请选择完整的日志创建时间范围"])
|
||||
assert.equal(state.showSearch.value, true)
|
||||
}
|
||||
})
|
||||
|
||||
test("时间范围按自然月限制,跨月、月底和闰年边界与后端一致", async () => {
|
||||
for (const range of [
|
||||
["2026-01-31 08:30:00", "2026-02-28 08:30:00"],
|
||||
["2024-01-31 08:30:00", "2024-02-29 08:30:00"],
|
||||
["2026-10-01 00:00:00", "2026-11-01 00:00:00"],
|
||||
["2026-10-29 00:00:00", "2026-10-29 00:00:00"],
|
||||
]) {
|
||||
const { state, requests, errors } = setup()
|
||||
state.state.queryForm.createTime = range
|
||||
await state.exportExcel()
|
||||
assert.deepEqual(errors, [])
|
||||
assert.equal(requests.length, 1)
|
||||
assert.deepEqual(requests[0].data.createTime, range)
|
||||
}
|
||||
for (const range of [
|
||||
["2026-01-31 08:30:00", "2026-02-28 08:30:01"],
|
||||
["2024-01-31 08:30:00", "2024-02-29 08:30:01"],
|
||||
["2026-10-01 00:00:00", "2026-11-01 00:00:01"],
|
||||
]) {
|
||||
const { state, requests, errors } = setup()
|
||||
state.state.queryForm.createTime = range
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 0)
|
||||
assert.deepEqual(errors, ["日志导出时间范围不能超过一个月"])
|
||||
}
|
||||
})
|
||||
|
||||
test("无效日期和倒序日期不提交,后台任务失败后可以修改条件重试", async () => {
|
||||
const { state, requests, errors } = setup()
|
||||
state.state.queryForm.createTime = ["2026-02-30 00:00:00", "2026-03-01 00:00:00"]
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 0)
|
||||
assert.equal(errors.at(-1), "请选择有效的日志创建时间范围")
|
||||
state.state.queryForm.createTime = ["2026-10-30 00:00:00", "2026-10-29 00:00:00"]
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 0)
|
||||
assert.equal(errors.at(-1), "日志创建时间的开始时间不能晚于结束时间")
|
||||
state.state.queryForm.createTime = ["2026-10-29 00:00:00", "2026-10-30 00:00:00"]
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
state.state.queryForm.createTime = null
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1, "执行中的任务继续查看进度,不受新筛选条件影响")
|
||||
state.handleExportFailure({ message: "当前筛选条件没有可导出的日志" })
|
||||
assert.equal(errors.at(-1), "当前筛选条件没有可导出的日志")
|
||||
state.state.queryForm.createTime = ["2026-10-28 00:00:00", "2026-10-29 00:00:00"]
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 2)
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vue = require('vue')
|
||||
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||
const { compile, root, runtime } = require('./helpers/file-access-runtime.cjs')
|
||||
|
||||
function setup(relative, props, request, fileAccess = {}) {
|
||||
const descriptor = parse(fs.readFileSync(path.join(root, relative), 'utf8')).descriptor
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: relative, id: 'upload-transport' }).errors, [])
|
||||
const errors = [], events = [], loaded = { exports: {} }
|
||||
const vueHooks = { ...vue, onBeforeUnmount() {} }
|
||||
const mocks = {
|
||||
vue: vueHooks,
|
||||
'/@/utils/request': { default: request },
|
||||
'/@/utils/storage': { Session: { get: () => 'token', getTenant: () => '42' } },
|
||||
'/@/hooks/message': { useMessage: () => ({ error: value => errors.push(value) }) },
|
||||
'/@/utils/other': { default: { adaptationUrl: url => url, addUnit: value => value } },
|
||||
'/@/utils/file-access': fileAccess,
|
||||
'/@/utils/file-access-dom': { bindFileAccessDom() {} },
|
||||
'vue-i18n': { useI18n: () => ({ t: key => key }) },
|
||||
'@wangeditor/editor/dist/css/style.css': {},
|
||||
'@wangeditor/editor-for-vue': { Toolbar: {}, Editor: {} },
|
||||
}
|
||||
const globals = { ref: vue.ref, computed: vue.computed, watch: vue.watch,
|
||||
getCurrentInstance: () => ({ proxy: { baseURL: '/api' } }), onMounted() {}, nextTick: vue.nextTick }
|
||||
new Function('module', 'exports', 'require', ...Object.keys(globals), compile(compileScript(descriptor, { id: 'upload-transport' }).content))(
|
||||
loaded, loaded.exports, name => name in mocks ? mocks[name] : require(name), ...Object.values(globals))
|
||||
const state = loaded.exports.default.setup(props, { expose() {}, emit: (...args) => events.push(args) })
|
||||
return { state, errors, events }
|
||||
}
|
||||
|
||||
test('generic uploads preserve multipart fields, delegate URL adaptation to the common transport, and finish a batch after one file fails', async () => {
|
||||
const requests = []
|
||||
const file = new File(['%PDF-1.4\n%%EOF'], 'report.PDF', { type: 'application/pdf' })
|
||||
for (const type of ['default', 'simple']) {
|
||||
const { state, errors, events } = setup('src/components/Upload/index.vue', {
|
||||
type, modelValue: '', fileType: ['pdf'], fileSize: 100, data: {}, dir: 'reports', uploadFileUrl: '/admin/sys-file/upload',
|
||||
}, async options => { requests.push(options); return { code: 0, data: { url: '/admin/sys-file/oss/file?fileName=report.pdf' } } })
|
||||
assert.equal(state.handleBeforeUpload(file), true)
|
||||
assert.equal(state.handleBeforeUpload(file), true)
|
||||
const result = await state.handleHttpUpload({ action: '/api/admin/sys-file/upload', filename: 'file', file,
|
||||
data: { dir: 'reports', groupId: 12, type: 'pdf' } })
|
||||
const request = requests.at(-1)
|
||||
assert.equal(request.url, '/admin/sys-file/upload')
|
||||
assert.equal(request.baseURL, undefined)
|
||||
assert.equal(request.data.get('file').name, 'report.PDF')
|
||||
assert.equal(request.data.get('dir'), 'reports')
|
||||
assert.equal(request.data.get('groupId'), '12')
|
||||
assert.equal(request.data.get('type'), 'pdf')
|
||||
state.handleUploadSuccess(result, { name: file.name, raw: file, size: file.size })
|
||||
assert.equal(events.length, 0)
|
||||
state.handleUploadError({ msg: '不支持上传此文件类型' })
|
||||
assert.deepEqual(errors, ['不支持上传此文件类型'])
|
||||
assert.equal(state.number.value, 0)
|
||||
assert.equal(events.find(event => event[0] === 'update:modelValue')[1], '/admin/sys-file/oss/file?fileName=report.pdf')
|
||||
}
|
||||
})
|
||||
|
||||
test('rich text images and videos use the common transport, preserve raw saved references, and propagate upload failures', async () => {
|
||||
const env = runtime(), requests = [], inserted = []
|
||||
const raw = '/admin/sys-file/oss/file?fileName=photo.png'
|
||||
let result = { code: 0, data: { url: raw } }
|
||||
let transportError
|
||||
try {
|
||||
const { state, events } = setup('src/components/Editor/index.vue', {
|
||||
getHtml: '', placeholder: '', height: '310', width: 'auto', uploadFileUrl: '/admin/sys-file/upload',
|
||||
}, async options => { requests.push(options); if (transportError) throw transportError; return result }, env.api)
|
||||
for (const name of ['uploadImage', 'uploadVideo']) {
|
||||
const file = new File(['test content'], name === 'uploadImage' ? 'photo.png' : 'video.mp4')
|
||||
await state.state.editorConfig.MENU_CONF[name].customUpload(file, value => inserted.push(value))
|
||||
assert.equal(requests.at(-1).url, '/admin/sys-file/upload')
|
||||
assert.equal(requests.at(-1).method, 'post')
|
||||
assert.equal(requests.at(-1).data.get('file').name, file.name)
|
||||
}
|
||||
state.handleChange({ getHtml: () => '<img src="' + env.api.fileUrl(raw) + '">', getText: () => '' })
|
||||
const saved = events.find(event => event[0] === 'update:getHtml')[1]
|
||||
assert.doesNotMatch(saved, /[?&](?:sign|appId|kid|ts)=/)
|
||||
assert.match(saved, /fileName=photo\.png/)
|
||||
result = { code: 1, msg: '文件内容与后缀类型不符' }
|
||||
await assert.rejects(state.uploadAttr.customUpload(new File(['bad'], 'bad.png'), value => inserted.push(value)), /文件内容与后缀类型不符/)
|
||||
transportError = { msg: '上传身份校验失败' }
|
||||
await assert.rejects(state.uploadAttr.customUpload(new File(['bad'], 'bad.png'), value => inserted.push(value)), /上传身份校验失败/)
|
||||
assert.equal(inserted.length, 2)
|
||||
} finally { env.close() }
|
||||
})
|
||||
@@ -23,6 +23,13 @@ const alias: Record<string, string> = {
|
||||
|
||||
const viteConfig = defineConfig((mode: ConfigEnv) => {
|
||||
const env = loadEnv(mode.mode, process.cwd())
|
||||
const fileEnv = loadEnv(mode.mode, process.cwd(), '')
|
||||
if (fileEnv.VITE_FILE_ACCESS_APP_ID !== 'admin' || !/^[a-zA-Z0-9_-]{1,32}$/.test(fileEnv.VITE_FILE_ACCESS_KEY_ID || '')
|
||||
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(fileEnv.VITE_FILE_ACCESS_KEY || '')
|
||||
|| Buffer.from(fileEnv.VITE_FILE_ACCESS_KEY || '', 'base64').length < 32) {
|
||||
throw new Error('请配置文件访问 appId、kid 和至少 32 字节的 Base64 密钥(.env.<mode>.local)')
|
||||
}
|
||||
|
||||
// 判断是否开发环境
|
||||
const isDev = env.ENV === "development"
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user