This commit is contained in:
czhqwer
2025-03-09 00:30:04 +08:00
commit 46bdc08cd5
61 changed files with 5647 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead
+17
View File
@@ -0,0 +1,17 @@
module.exports = {
root: true,
env: {
node: true
},
'extends': [
'plugin:vue/essential',
'eslint:recommended'
],
parserOptions: {
parser: '@babel/eslint-parser'
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
}
}
+23
View File
@@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "upload-file-component",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^1.8.1",
"axios-extra": "^0.0.8",
"core-js": "^3.8.3",
"element-ui": "^2.15.14",
"promise-queue-plus": "^1.2.2",
"spark-md5": "^3.0.2",
"vue": "^2.6.14",
"vue-router": "^3.5.1",
"vue-virtual-scroller": "^1.1.2",
"vuex": "^3.6.2"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.32.7",
"sass-loader": "^12.0.0",
"vue-template-compiler": "^2.6.14"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
<template>
<div id="app">
<div class="app-container">
<router-view />
</div>
</div>
</template>
<script>
export default {
name: 'App',
components: {
}
};
</script>
<style scoped>
#app {
box-sizing: border-box;
background: linear-gradient(135deg, #f5f9ff 0%, #e6f0ff 100%);
height: 100vh;
padding: 0;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex;
justify-content: center;
align-items: flex-start;
overflow: auto;
padding-top: 80px;
}
.app-container {
width: 90%;
max-width: 1000px;
margin: 0 auto;
}
</style>
<style>
/* 全局样式 */
body, html {
margin: 0;
padding: 0;
height: 100%;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -0,0 +1,60 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br>
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router" target="_blank" rel="noopener">router</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
</ul>
<h3>Essential Links</h3>
<ul>
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
</ul>
</div>
</template>
<script>
export default {
name: 'HelloWorld',
props: {
msg: String
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
Vue.use(ElementUI)
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
+21
View File
@@ -0,0 +1,21 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import FileUpload from '@/views/FileUpload.vue';
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'home',
component: FileUpload
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
+17
View File
@@ -0,0 +1,17 @@
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
getters: {
},
mutations: {
},
actions: {
},
modules: {
}
})
+138
View File
@@ -0,0 +1,138 @@
import { http, httpExtra } from './http';
/**
* 获取上传进度
* @param {string} identifier 文件的 MD5 标识
* @param {string} storageType 存储类型
* @returns {Promise<any>} 上传进度信息
*/
const getUploadProgress = (identifier, storageType) => {
return http.get('/upload/getUploadProgress', {
params: { identifier },
headers: { 'Storage-Type': storageType }
});
};
/**
* 创建分片上传任务
* @param {Object} params 参数对象
* @param {string} params.identifier 文件的 MD5 标识
* @param {string} params.fileName 文件名称
* @param {number} params.totalSize 文件总大小 (字节)
* @param {number} params.chunkSize 分片大小 (字节)
* @param {string} params.contentType 文件 MIME 类型
* @param {string} params.folder 文件存储文件夹
* @param {string} params.storageType 存储类型
* @returns {Promise<any>} 分片上传任务信息
*/
const createMultipartUpload = ({ identifier, fileName, totalSize, chunkSize, contentType, folder, storageType }) => {
return http.post('/upload/createMultipartUpload', {
identifier,
fileName,
totalSize,
chunkSize,
contentType,
folder
}, {
headers: { 'Storage-Type': storageType }
});
};
/**
* 获取预签名分片上传 URL
* @param {Object} params 参数对象
* @param {string} params.identifier 文件的 MD5 标识
* @param {number} params.partNumber 分片编号
* @param {string} params.storageType 存储类型
* @returns {Promise<any>} 预签名上传 URL
*/
const getPreSignUploadUrl = ({ identifier, partNumber, storageType }) => {
return http.get('/upload/getPreSignUploadUrl', {
params: { identifier, partNumber },
headers: { 'Storage-Type': storageType }
});
};
/**
* 合并分片
* @param {string} identifier 文件的 MD5 标识
* @param {string} storageType 存储类型
* @returns {Promise<any>} 合并结果
*/
const merge = (identifier, storageType) => {
return http.post('/upload/merge', null, {
params: { identifier },
headers: { 'Storage-Type': storageType }
});
};
/**
* 上传文件到后端 /upload 接口
* @param {Object} params 参数对象
* @param {File} params.file 文件对象
* @param {string} [params.folder] 文件夹名称 (可选)
* @param {number} [params.fileType] 文件类型 (可选)
* @param {string} params.storageType 存储类型
* @returns {Promise<any>} 上传结果
*/
const uploadFile = ({ file, folder, fileType, storageType }) => {
const formData = new FormData();
formData.append('file', file); // 文件字段
if (folder) formData.append('folder', folder); // 可选的 folder 参数
if (fileType !== undefined) formData.append('fileType', fileType); // 可选的 fileType 参数
return http.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Storage-Type': storageType
}
});
};
/**
* 上传单个分片
* @param {Object} params 参数对象
* @param {string} params.uploadId 上传任务 ID
* @param {number} params.partNumber 分片编号
* @param {File} params.partFile 分片文件
* @param {string} params.storageType 存储类型
* @returns {Promise<any>} 上传结果
*/
const uploadPart = ({ uploadId, partNumber, partFile, storageType }) => {
const formData = new FormData();
formData.append('uploadId', uploadId);
formData.append('partNumber', partNumber);
formData.append('file', partFile);
return http.post('/upload/part', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Storage-Type': storageType
}
});
};
/**
* 分页获取文件列表
* @param {Object} params 参数对象
* @param {number} [params.page=1] 页码,默认 1
* @param {number} [params.pageSize=10] 每页大小,默认 10
* @param {string} params.storageType 存储类型
* @returns {Promise<any>} 文件列表
*/
const pageFiles = ({ page = 1, pageSize = 10, storageType, fileName }) => {
return http.get('/file/page', {
params: { page, pageSize, storageType, fileName }
});
};
export {
getUploadProgress,
createMultipartUpload,
getPreSignUploadUrl,
merge,
uploadFile,
uploadPart,
pageFiles,
httpExtra
};
+25
View File
@@ -0,0 +1,25 @@
import axios from 'axios';
import axiosExtra from 'axios-extra';
const baseUrl = 'http://localhost:10086';
// 基础 Axios 实例
const http = axios.create({
baseURL: baseUrl
});
// 配置拦截器,返回 response.data
http.interceptors.response.use(response => {
return response.data;
});
// 配置 axios-extra 实例,用于控制并发和重试
const httpExtra = axiosExtra.create({
maxConcurrent: 5, // 并发数为 5
queueOptions: {
retry: 3, // 请求失败时最多重试 3 次
retryIsJump: false // 重试时插入队列尾部而非立即重试
}
});
export { http, baseUrl, httpExtra };
+40
View File
@@ -0,0 +1,40 @@
import SparkMD5 from 'spark-md5'
const DEFAULT_SIZE = 20 * 1024 * 1024
const md5 = (file, chunkSize = DEFAULT_SIZE) => {
return new Promise((resolve, reject) => {
const startMs = new Date().getTime();
let blobSlice =
File.prototype.slice ||
File.prototype.mozSlice ||
File.prototype.webkitSlice;
let chunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;
let spark = new SparkMD5.ArrayBuffer(); //追加数组缓冲区。
let fileReader = new FileReader(); //读取文件
fileReader.onload = function (e) {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
const md5 = spark.end(); //完成md5的计算,返回十六进制结果。
console.log('文件md5计算结束,总耗时:', (new Date().getTime() - startMs) / 1000, 's')
resolve(md5);
}
};
fileReader.onerror = function (e) {
reject(e);
};
function loadNext() {
console.log('当前part number', currentChunk, '总块数:', chunks);
let start = currentChunk * chunkSize;
let end = start + chunkSize;
(end > file.size) && (end = file.size);
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
loadNext();
});
}
export default md5
@@ -0,0 +1,5 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
@@ -0,0 +1,707 @@
<template>
<div class="upload-container">
<div class="upload-window">
<!-- 标题栏 -->
<div class="header-wrapper">
<h1 class="page-title">
<span class="cloud-icon"></span>
文件上传中心
</h1>
<div class="menu-container" @mouseenter="showMenu = true" @mouseleave="showMenu = false">
<div class="menu-icon"></div>
<div v-show="showMenu" class="dropdown-menu">
<div class="menu-item" @click="openSettings">设置</div>
<div class="menu-item" @click="openAbout">关于</div>
</div>
</div>
</div>
<!-- 标签导航 -->
<div class="tab-bar">
<div v-for="tab in tabs" :key="tab.value" :class="['tab', { 'active': activeTab === tab.value }]"
@click="switchTab(tab.value)">
{{ tab.label }}
<div class="tab-underline"></div>
</div>
</div>
<!-- 上传区域 -->
<div v-if="activeTab !== 'gallery' && !isWipTab" class="upload-content">
<upload-file :file-list.sync="fileList" :accept="allowedFormats" :max-size="maxUploadSize" width="100%"
height="400px" :storage-type="activeTab" />
</div>
<!-- 未实现功能提示 -->
<div v-else-if="isWipTab" class="wip-container">
<div class="wip-message">
<span class="wip-icon">🚧</span>
<h2>功能建设中</h2>
<p v-if="activeTab === 'obs'">正在加班加点搬砖中敬请期待</p>
<p v-else-if="activeTab === 'qiniu'">七牛云功能正在被疯狂调教马上就能和大家见面啦</p>
</div>
</div>
<!-- 图片展示区域 -->
<div v-else class="gallery-container" @scroll="handleScroll">
<div class="gallery-header">
<label for="storage-select">存储类型:</label>
<select id="storage-select" v-model="selectedStorageType" @change="fetchFiles(true)">
<option v-for="option in storageOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<label for="storage-select">&nbsp;&nbsp;&nbsp;&nbsp;文件名:</label>
<el-input v-model="selectedFileName" placeholder="请输入文件名" class="file-name-input" style="width: 150px;" @change="fetchFiles(true)"></el-input>
</div>
<div v-if="filePage.length === 0 && !loading" class="empty-tips">
🖼 暂无已上传的文件
</div>
<div v-else-if="loading && filePage.length === 0" class="loading-tips">
加载中...
</div>
<div v-else class="file-grid">
<div v-for="file in filePage" :key="file.id" class="file-card">
<div class="preview-wrapper">
<img v-if="isImage(file)" :src="file.accessUrl" :alt="file.fileName" class="preview-image" />
<div v-else class="file-icon">
📄
</div>
</div>
<div class="file-meta">
<div class="filename">{{ file.fileName }}</div>
<div class="file-size-status">
<div class="file-size">{{ formatSize(file.totalSize) }}</div>
<div class="status-indicator">{{ formattedDate(file.createTime) }}</div>
</div>
</div>
</div>
</div>
<div v-if="loading && filePage.length > 0" class="loading-more">
加载更多...
</div>
</div>
<!-- 设置表单弹窗形式 -->
<div v-if="showSettings" class="settings-modal" @click="closeSettingsOnOutside">
<div class="settings-content" @click.stop>
<h2>上传设置</h2>
<form @submit.prevent="saveSettings">
<div class="form-group">
<label>允许上传的文件格式用英文逗号分隔例如 .jpg,.png:</label>
<input v-model="tempAllowedFormats" type="text" placeholder=".jpg,.png,.mp4" />
</div>
<div class="form-group">
<label>最大上传大小MB:</label>
<input v-model.number="tempMaxSizeMB" type="number" min="1" step="1" />
</div>
<div class="form-actions">
<button type="submit">保存</button>
<button type="button" @click="closeSettings">取消</button>
</div>
</form>
</div>
</div>
<!-- 关于弹窗 -->
<div v-if="showAbout" class="about-modal" @click="closeAboutOnOutside">
<div class="about-content" @click.stop>
<h2>关于</h2>
<p>这个人很懒什么都没有留下只说自己爱吃炸排骨</p>
</div>
</div>
</div>
</div>
</template>
<script>
import UploadFile from '@/components/UploadFile/UploadFile.vue';
import { pageFiles } from '@/utils/api';
export default {
name: 'FileUpload',
components: { UploadFile },
data() {
return {
fileList: [],
activeTab: 'local',
tabs: [
{ label: 'Local', value: 'local' },
{ label: 'MinIO', value: 'minio' },
{ label: 'OSS', value: 'oss' },
{ label: 'OBS', value: 'obs' },
{ label: 'QiNiu', value: 'qiniu' },
{ label: '已上传文件', value: 'gallery' }
],
showMenu: false,
showSettings: false,
showAbout: false,
allowedFormats: '.jpg,.png,.mp4',
maxUploadSize: 100 * 1024 * 1024,
tempAllowedFormats: '',
tempMaxSizeMB: 100,
// 分页相关
selectedStorageType: 'local', // 默认存储类型
selectedFileName: '',
storageOptions: [
{ label: 'Local', value: 'local' },
{ label: 'MinIO', value: 'minio' },
{ label: 'OSS', value: 'oss' }
],
currentPage: 1,
pageSize: 10,
loading: false,
hasMore: true, // 是否还有更多数据
filePage: []
};
},
computed: {
formattedDate() {
return (date) => {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
},
isWipTab() {
return this.activeTab === 'obs' || this.activeTab === 'qiniu';
}
},
methods: {
switchTab(tabValue) {
this.activeTab = tabValue;
if (tabValue === 'gallery') {
this.fetchFiles(true);
} else if (this.isWipTab) {
this.$message({
message: tabValue === 'obs'
? 'OBS功能正在施工中,小哥哥们正在挥汗如雨!'
: '七牛云功能开发中,程序员正在和咖啡斗智斗勇!',
type: 'info',
duration: 2000
});
}
},
isImage(file) {
return file.contentType.startsWith('image/');
},
formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
openSettings() {
this.tempAllowedFormats = this.allowedFormats;
this.tempMaxSizeMB = this.maxUploadSize / (1024 * 1024);
this.showSettings = true;
this.showMenu = false;
},
saveSettings() {
this.allowedFormats = this.tempAllowedFormats;
this.maxUploadSize = this.tempMaxSizeMB * 1024 * 1024;
this.showSettings = false;
},
closeSettings() {
this.showSettings = false;
},
closeSettingsOnOutside(event) {
if (event.target.classList.contains('settings-modal')) {
this.closeSettings();
}
},
openAbout() {
this.showAbout = true;
this.showMenu = false;
},
closeAboutOnOutside(event) {
if (event.target.classList.contains('about-modal')) {
this.showAbout = false;
}
},
// 获取文件列表
async fetchFiles(reset = false) {
console.log('fetchFiles called with reset:', reset, this.loading, this.hasMore); // 调试日志
if (this.loading || (!reset && !this.hasMore)) return;
this.loading = true;
if (reset) {
this.currentPage = 1;
this.filePage = [];
this.hasMore = true;
}
try {
const response = await pageFiles({
page: this.currentPage,
pageSize: this.pageSize,
storageType: this.selectedStorageType,
fileName: this.selectedFileName
});
const files = response.data.records || [];
this.filePage = reset ? files : this.filePage.concat(files);
this.hasMore = files.length === this.pageSize;
if (this.hasMore) this.currentPage++;
} catch (error) {
console.error('获取文件列表失败:', error);
this.$message.error('加载文件列表失败');
} finally {
this.loading = false;
}
},
// 滚动触底加载
handleScroll(event) {
const container = event.target;
const isBottom =
container.scrollTop + container.clientHeight >= container.scrollHeight - 10; // 提前 10px 触发
if (isBottom && !this.loading && this.hasMore) {
this.fetchFiles();
}
}
}
};
</script>
<style scoped>
.wip-container {
padding: 2rem;
height: 400px;
background: #fcfdff;
border-top: 1px solid #f0f7ff;
display: flex;
justify-content: center;
align-items: center;
}
.wip-message {
text-align: center;
color: #666;
}
.wip-icon {
font-size: 3rem;
margin-bottom: 1rem;
display: block;
}
.wip-message h2 {
font-size: 1.5rem;
color: #1976d2;
margin: 0.5rem 0;
}
.wip-message p {
font-size: 1rem;
max-width: 400px;
margin: 0 auto;
line-height: 1.5;
}
.upload-container {
width: 100%;
background: #ffffff;
border-radius: 14px;
box-shadow: 0 12px 24px rgba(25, 118, 210, 0.1);
overflow: hidden;
border: 1px solid #e3f2fd;
}
/* 标题栏布局 */
.header-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
background: #1976d2;
}
.page-title {
margin: 0;
color: white;
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 12px;
}
.cloud-icon {
font-size: 1.8rem;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
/* 菜单图标和下拉样式 */
.menu-container {
position: relative;
}
.menu-icon {
color: white;
font-size: 1.8rem;
cursor: pointer;
padding: 0 10px;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background: #fff;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 120px;
z-index: 10;
}
.menu-item {
padding: 8px 16px;
color: #333;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.menu-item:hover {
background: #f0f7ff;
}
/* 标签导航样式 */
.tab-bar {
display: flex;
padding: 0 2rem;
background: #f8fafd;
border-bottom: 1px solid #e0eefc;
gap: 8px;
position: relative;
}
.tab {
position: relative;
padding: 14px 32px;
font-size: 14px;
color: #607d9f;
cursor: pointer;
background: transparent;
border: none;
border-radius: 6px 6px 0 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
gap: 4px;
}
.tab:hover {
color: #1565c0;
background: rgba(25, 118, 210, 0.05);
}
.tab.active {
color: #1976d2;
font-weight: 500;
}
.tab.active .tab-underline {
width: 100%;
background: #1976d2;
}
.tab-underline {
height: 2px;
width: 0;
background: transparent;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 上传和展示区域样式 */
.upload-content {
padding: 2rem;
background: #fcfdff;
min-height: 400px;
border-top: 1px solid #f0f7ff;
}
.gallery-container {
padding: 1rem 2rem 2rem;
height: 400px;
background: #fcfdff;
border-top: 1px solid #f0f7ff;
overflow-y: auto;
}
.gallery-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 1rem;
}
.gallery-header label {
font-size: 14px;
color: #333;
}
.gallery-header select {
padding: 6px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
background: #fff;
cursor: pointer;
}
.empty-tips,
.loading-tips {
text-align: center;
color: #999;
font-size: 1.2rem;
padding: 4rem 0;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.loading-more {
text-align: center;
color: #666;
font-size: 14px;
padding: 1rem 0;
}
.file-card {
background: #fff;
border-radius: 6px;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.file-card:hover {
transform: translateY(-2px);
box-shadow: 0 3px 8px rgba(25, 118, 210, 0.2);
}
.preview-wrapper {
position: relative;
padding-top: 100%;
background: #f8fafd;
}
.preview-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-bottom: 1px solid #eee;
}
.file-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 2rem;
opacity: 0.6;
}
.file-meta {
padding: 0.8rem;
display: flex;
flex-direction: column;
}
.file-size-status {
display: flex;
justify-content: space-between;
align-items: center;
}
.filename {
font-weight: 500;
color: #333;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 0.7rem;
color: #666;
margin: 0.2rem 0;
}
.status-indicator {
display: inline-block;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.65rem;
background: #e8f5e9;
color: #2e7d32;
}
/* 设置弹窗样式 */
.settings-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.settings-content {
background: #fff;
padding: 20px;
border-radius: 8px;
width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.settings-content h2 {
margin-top: 0;
font-size: 1.2rem;
color: #1976d2;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-size: 14px;
color: #333;
}
.form-group input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.form-actions button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.form-actions button[type="submit"] {
background: #1976d2;
color: white;
}
.form-actions button[type="button"] {
background: #eee;
color: #333;
}
/* 关于弹窗样式 */
.about-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.about-content {
background: #fff;
padding: 20px;
border-radius: 8px;
width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
text-align: center;
}
.about-content h2 {
margin-top: 0;
font-size: 1.2rem;
color: #1976d2;
}
.about-content p {
margin: 10px 0;
font-size: 14px;
color: #666;
}
/* 响应式调整 */
@media (max-width: 768px) {
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
.gallery-container {
padding: 1rem;
height: 400px;
}
.upload-container {
width: 95%;
border-radius: 12px;
}
.header-wrapper {
padding: 1rem;
}
.page-title {
font-size: 1.2rem;
}
.tab-bar {
padding: 0 1rem;
overflow-x: auto;
}
.tab {
padding: 12px 20px;
font-size: 13px;
flex-shrink: 0;
}
.upload-content {
padding: 1.5rem;
}
.settings-content,
.about-content {
width: 90%;
}
}
@media (max-width: 480px) {
.tab {
padding: 10px 16px;
}
}
</style>
@@ -0,0 +1,18 @@
<template>
<div class="home">
<img alt="Vue logo" src="../assets/logo.png">
<HelloWorld msg="Welcome to Your Vue.js App"/>
</div>
</template>
<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'
export default {
name: 'HomeView',
components: {
HelloWorld
}
}
</script>
+4
View File
@@ -0,0 +1,4 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true
})