fix:图片访问

fix:权限修改
This commit is contained in:
dzc committed 2026-10-29 14:26:56 +08:00
1 parent dec9fd96e3
commit d86bc810cc
69 files changed
+1264 -458

No files matched your search

+67
View File
@@ -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"]])
})
+131
View File
@@ -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,/&amp;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');
});
+164
View 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() }
})
+36
View File
@@ -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 }
+93
View File
@@ -0,0 +1,93 @@
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,
"/@/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 () => {
const { state, requests, errors } = setup()
await state.exportExcel()
assert.deepEqual(requests[0].data, { logType: "", createTime: [], serviceId: "" })
assert.equal(state.state.queryForm.createTime, "")
state.handleExportFailure({ message: "当前筛选条件没有可导出的日志" })
assert.deepEqual(errors, ["当前筛选条件没有可导出的日志"])
state.state.queryForm.createTime = null
await state.exportExcel()
assert.equal(requests.length, 2)
assert.deepEqual(requests[1].data.createTime, [])
})