feat: 分片上传断点续传、临时目录后台配置与清扫、宇恒云账号管理
- 管理端大文件分片上传与 sessionStorage 续传;Nginx 大请求体/超时 - .chunk-uploads 定期清扫;system_config 后台配置保留时长与扫描间隔 - 宇恒云 POST /register 对接与 yuheng_cloud_register_records 留痕;yuheng_cloud:manage 权限 Made-with: Cursor
This commit is contained in:
@@ -28,6 +28,11 @@ export const createUser = (data) => request.post('/admin/users', data)
|
||||
export const updateUser = (id, data) => request.put(`/admin/users/${id}`, data)
|
||||
export const deleteUser = (id) => request.delete(`/admin/users/${id}`)
|
||||
|
||||
// 宇恒云账号(POST 云端 /register + 本地 Mongo 仅记 username/password)
|
||||
export const createYuhengCloudAccount = (data) =>
|
||||
request.post('/admin/yuheng-cloud-accounts', data, { timeout: 60000 })
|
||||
export const listYuhengCloudAccounts = (params) => request.get('/admin/yuheng-cloud-accounts', { params })
|
||||
|
||||
// 工作空间
|
||||
export const getWorkspaces = (params) => request.get('/admin/workspaces', { params })
|
||||
|
||||
@@ -46,6 +51,10 @@ export const updatePaymentConfig = (data) => request.put('/admin/payment-config'
|
||||
export const getOfficialSite = () => request.get('/admin/system/official-site')
|
||||
export const setOfficialSite = (siteId) => request.put('/admin/system/official-site', { site_id: siteId })
|
||||
|
||||
/** 分片上传临时目录清理:保留时长、扫描间隔(需 site:manage) */
|
||||
export const getChunkUploadCleanup = () => request.get('/admin/system/chunk-upload-cleanup')
|
||||
export const updateChunkUploadCleanup = (data) => request.put('/admin/system/chunk-upload-cleanup', data)
|
||||
|
||||
// 站点管理
|
||||
export const getSites = () => request.get('/admin/sites')
|
||||
export const getSiteById = (id) => request.get(`/admin/sites/${id}`)
|
||||
@@ -79,8 +88,33 @@ export const uploadSiteAsset = (siteId, file, opts = {}) => {
|
||||
if (opts.folder != null) form.append('folder', opts.folder)
|
||||
form.append('downloadable', opts.downloadable ? 'true' : 'false')
|
||||
if (opts.preserveFilename) form.append('preserve_filename', 'true')
|
||||
return request.post(`/admin/sites/${siteId}/assets`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
// 大文件上传:timeout 0 = Axios 不设置请求超时(仍可能受浏览器/系统/代理断开影响)
|
||||
// 超大文件请用分片 API(见 uploadSiteAssetWithResume)
|
||||
return request.post(`/admin/sites/${siteId}/assets`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 0
|
||||
})
|
||||
}
|
||||
|
||||
/** 分片上传:创建会话(断点续传) */
|
||||
export const initMultipartUpload = (siteId, body) =>
|
||||
request.post(`/admin/sites/${siteId}/assets/init-multipart`, body, { timeout: 60000 })
|
||||
|
||||
export const getMultipartUploadStatus = (siteId, uploadId) =>
|
||||
request.get(`/admin/sites/${siteId}/assets/multipart/${uploadId}/status`, { timeout: 60000 })
|
||||
|
||||
export const putMultipartChunk = (siteId, uploadId, chunkIndex, blob) =>
|
||||
request.put(`/admin/sites/${siteId}/assets/multipart/${uploadId}/chunk/${chunkIndex}`, blob, {
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
timeout: 180000
|
||||
})
|
||||
|
||||
export const completeMultipartUpload = (siteId, uploadId) =>
|
||||
request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/complete`, {}, { timeout: 600000 })
|
||||
|
||||
export const abortMultipartUpload = (siteId, uploadId) =>
|
||||
request.delete(`/admin/sites/${siteId}/assets/multipart/${uploadId}`, { timeout: 60000 })
|
||||
|
||||
export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })
|
||||
export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key, VideoCamera } from '@element-plus/icons-vue'
|
||||
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key, VideoCamera, Timer, Link } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getMyPermissions } from '../api/admin'
|
||||
|
||||
@@ -69,6 +69,8 @@ const menuItems = computed(() => {
|
||||
{ index: '/sms-config', title: '短信配置', icon: Setting, permission: 'sms_config' },
|
||||
{ index: '/payment-config', title: '支付配置', icon: Wallet, permission: 'payment_config' },
|
||||
{ index: '/sites', title: '站点管理', icon: Monitor, permission: 'site:manage' },
|
||||
{ index: '/chunk-upload-cleanup', title: '分片上传清理', icon: Timer, permission: 'site:manage' },
|
||||
{ index: '/yuheng-cloud-accounts', title: '宇恒云账号', icon: Link, permission: 'yuheng_cloud:manage' },
|
||||
{ index: '/pages', title: '网页管理', icon: Document, permission: 'page:manage' },
|
||||
{ index: '/homepage-edit', title: '首页编辑', icon: EditPen, permission: 'homepage:edit' },
|
||||
{ index: '/live-broadcast', title: '视频直播开播', icon: VideoCamera, permission: 'homepage:edit' },
|
||||
|
||||
@@ -48,6 +48,18 @@ const routes = [
|
||||
component: () => import('../views/settings/PaymentConfig.vue'),
|
||||
meta: { title: '支付配置', permission: 'payment_config' }
|
||||
},
|
||||
{
|
||||
path: 'chunk-upload-cleanup',
|
||||
name: 'ChunkUploadCleanup',
|
||||
component: () => import('../views/settings/ChunkUploadCleanup.vue'),
|
||||
meta: { title: '分片上传清理', permission: 'site:manage' }
|
||||
},
|
||||
{
|
||||
path: 'yuheng-cloud-accounts',
|
||||
name: 'YuhengCloudAccountManage',
|
||||
component: () => import('../views/settings/YuhengCloudAccountManage.vue'),
|
||||
meta: { title: '宇恒云账号', permission: 'yuheng_cloud:manage' }
|
||||
},
|
||||
{
|
||||
path: 'sites',
|
||||
name: 'Sites',
|
||||
|
||||
152
admin/src/utils/siteAssetResumableUpload.js
Normal file
152
admin/src/utils/siteAssetResumableUpload.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
abortMultipartUpload,
|
||||
completeMultipartUpload,
|
||||
getMultipartUploadStatus,
|
||||
initMultipartUpload,
|
||||
putMultipartChunk,
|
||||
uploadSiteAsset
|
||||
} from '../api/admin'
|
||||
|
||||
const CHUNK_THRESHOLD = 8 * 1024 * 1024
|
||||
const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
|
||||
const UPLOAD_CONCURRENCY = 3
|
||||
|
||||
function fileFingerprint(file) {
|
||||
return `${file.name}\t${file.size}\t${file.lastModified}`
|
||||
}
|
||||
|
||||
function storageKey(siteId) {
|
||||
return `yh_resumable_asset_${siteId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点资源上传:小于阈值整包 POST;大于等于阈值走分片 + sessionStorage 断点续传(同文件同站点刷新后可续传)。
|
||||
* @param {string} siteId
|
||||
* @param {File|Blob} file
|
||||
* @param {{ folder?: string, downloadable?: boolean, preserveFilename?: boolean }} opts
|
||||
* @param {{ onProgress?: (p: { percent: number, loaded: number, total: number }) => void }} callbacks
|
||||
*/
|
||||
export async function uploadSiteAssetWithResume(siteId, file, opts = {}, callbacks = {}) {
|
||||
const onProgress = typeof callbacks.onProgress === 'function' ? callbacks.onProgress : null
|
||||
const total = file.size
|
||||
if (total <= CHUNK_THRESHOLD) {
|
||||
if (onProgress) onProgress({ percent: 0, loaded: 0, total })
|
||||
const res = await uploadSiteAsset(siteId, file, opts)
|
||||
if (onProgress) onProgress({ percent: 100, loaded: total, total })
|
||||
return res
|
||||
}
|
||||
|
||||
const fp = fileFingerprint(file)
|
||||
const key = storageKey(siteId)
|
||||
let uploadId = null
|
||||
let chunkSize = DEFAULT_CHUNK_SIZE
|
||||
let totalChunks = 0
|
||||
const received = new Set()
|
||||
|
||||
let cached = null
|
||||
try {
|
||||
cached = JSON.parse(sessionStorage.getItem(key) || 'null')
|
||||
} catch (_) {
|
||||
cached = null
|
||||
}
|
||||
|
||||
if (cached && cached.fingerprint === fp && cached.upload_id) {
|
||||
try {
|
||||
const st = await getMultipartUploadStatus(siteId, cached.upload_id)
|
||||
if (
|
||||
st.total_size === file.size &&
|
||||
st.original_filename === file.name &&
|
||||
typeof st.chunk_size === 'number' &&
|
||||
st.chunk_size > 0
|
||||
) {
|
||||
uploadId = cached.upload_id
|
||||
chunkSize = st.chunk_size
|
||||
totalChunks = st.total_chunks
|
||||
for (const i of st.received_chunks || []) {
|
||||
received.add(i)
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
uploadId = null
|
||||
received.clear()
|
||||
}
|
||||
}
|
||||
|
||||
if (!uploadId) {
|
||||
const init = await initMultipartUpload(siteId, {
|
||||
filename: file.name,
|
||||
total_size: file.size,
|
||||
chunk_size: DEFAULT_CHUNK_SIZE,
|
||||
folder: opts.folder || '',
|
||||
downloadable: Boolean(opts.downloadable),
|
||||
preserve_filename: Boolean(opts.preserveFilename)
|
||||
})
|
||||
uploadId = init.upload_id
|
||||
chunkSize = init.chunk_size || DEFAULT_CHUNK_SIZE
|
||||
totalChunks = init.total_chunks
|
||||
sessionStorage.setItem(key, JSON.stringify({ fingerprint: fp, upload_id: uploadId, total_chunks: totalChunks }))
|
||||
}
|
||||
|
||||
const missing = []
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
if (!received.has(i)) missing.push(i)
|
||||
}
|
||||
|
||||
const chunkByteLength = (idx) => {
|
||||
const start = idx * chunkSize
|
||||
const end = Math.min(start + chunkSize, file.size)
|
||||
return end - start
|
||||
}
|
||||
|
||||
let uploadedBytes = 0
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
if (received.has(i)) uploadedBytes += chunkByteLength(i)
|
||||
}
|
||||
|
||||
const reportProgress = () => {
|
||||
if (!onProgress) return
|
||||
const pct = total <= 0 ? 100 : Math.min(99, Math.round((uploadedBytes / total) * 100))
|
||||
onProgress({ percent: pct, loaded: uploadedBytes, total })
|
||||
}
|
||||
reportProgress()
|
||||
|
||||
const queue = [...missing]
|
||||
const worker = async () => {
|
||||
while (queue.length) {
|
||||
const idx = queue.shift()
|
||||
if (idx === undefined) break
|
||||
const start = idx * chunkSize
|
||||
const end = Math.min(start + chunkSize, file.size)
|
||||
const blob = file.slice(start, end)
|
||||
await putMultipartChunk(siteId, uploadId, idx, blob)
|
||||
uploadedBytes += end - start
|
||||
reportProgress()
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, Math.max(1, missing.length)) }, () => worker()))
|
||||
|
||||
const done = await completeMultipartUpload(siteId, uploadId)
|
||||
try {
|
||||
sessionStorage.removeItem(key)
|
||||
} catch (_) {}
|
||||
if (onProgress) onProgress({ percent: 100, loaded: total, total })
|
||||
return done
|
||||
}
|
||||
|
||||
/** 放弃当前站点的分片会话(可选) */
|
||||
export async function abortSiteAssetResumable(siteId) {
|
||||
const key = storageKey(siteId)
|
||||
let cached = null
|
||||
try {
|
||||
cached = JSON.parse(sessionStorage.getItem(key) || 'null')
|
||||
} catch (_) {
|
||||
cached = null
|
||||
}
|
||||
if (!cached?.upload_id) return
|
||||
try {
|
||||
await abortMultipartUpload(siteId, cached.upload_id)
|
||||
} catch (_) {}
|
||||
try {
|
||||
sessionStorage.removeItem(key)
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
|
||||
<!-- 上传前选择是否可下载 -->
|
||||
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="440px" :close-on-click-modal="false">
|
||||
<p class="upload-resume-hint">≥8MB 将自动分片上传;中断后<strong>同一文件</strong>再次选择上传可续传(勿改文件名/大小)。</p>
|
||||
<el-form label-width="112px">
|
||||
<el-form-item label="当前目录">
|
||||
<span>{{ currentPath || '根目录' }}</span>
|
||||
@@ -68,6 +69,9 @@
|
||||
<el-form-item label="允许下载">
|
||||
<el-switch v-model="uploadDownloadable" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="uploading && uploadPercent > 0" label="进度">
|
||||
<el-progress :percentage="uploadPercent" :stroke-width="16" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="uploadDialogVisible = false">取消</el-button>
|
||||
@@ -93,7 +97,8 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getSites, getSiteAssets, uploadSiteAsset, deleteSiteAsset, createSiteFolder } from '../../api/admin'
|
||||
import { getSites, getSiteAssets, deleteSiteAsset, createSiteFolder } from '../../api/admin'
|
||||
import { uploadSiteAssetWithResume } from '../../utils/siteAssetResumableUpload'
|
||||
|
||||
const activeTab = ref('module')
|
||||
const siteId = ref('')
|
||||
@@ -103,6 +108,7 @@ const subDirs = ref([])
|
||||
const loading = ref(false)
|
||||
const currentPath = ref('')
|
||||
const uploading = ref(false)
|
||||
const uploadPercent = ref(0)
|
||||
const uploadDialogVisible = ref(false)
|
||||
const uploadDownloadable = ref(false)
|
||||
const uploadPreserveFilename = ref(false)
|
||||
@@ -167,12 +173,22 @@ const beforeUpload = (file) => {
|
||||
const doUpload = async () => {
|
||||
if (!pendingFile.value || !siteId.value) return
|
||||
uploading.value = true
|
||||
uploadPercent.value = 0
|
||||
try {
|
||||
await uploadSiteAsset(siteId.value, pendingFile.value, {
|
||||
folder: currentPath.value || undefined,
|
||||
downloadable: uploadDownloadable.value,
|
||||
preserveFilename: uploadPreserveFilename.value
|
||||
})
|
||||
await uploadSiteAssetWithResume(
|
||||
siteId.value,
|
||||
pendingFile.value,
|
||||
{
|
||||
folder: currentPath.value || undefined,
|
||||
downloadable: uploadDownloadable.value,
|
||||
preserveFilename: uploadPreserveFilename.value
|
||||
},
|
||||
{
|
||||
onProgress: ({ percent }) => {
|
||||
uploadPercent.value = percent
|
||||
}
|
||||
}
|
||||
)
|
||||
ElMessage.success('上传成功')
|
||||
uploadDialogVisible.value = false
|
||||
pendingFile.value = null
|
||||
@@ -181,6 +197,7 @@ const doUpload = async () => {
|
||||
ElMessage.error(e.response?.data?.error || e.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
uploadPercent.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,4 +247,10 @@ onMounted(() => fetchSites().then(() => fetchList()))
|
||||
.breadcrumb-wrap { margin-top: 12px; }
|
||||
.subdirs { margin-top: 8px; font-size: 13px; color: #666; }
|
||||
.subdirs .label { margin-right: 8px; }
|
||||
.upload-resume-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
91
admin/src/views/settings/ChunkUploadCleanup.vue
Normal file
91
admin/src/views/settings/ChunkUploadCleanup.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="chunk-upload-cleanup">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>分片上传临时目录清理</span>
|
||||
</template>
|
||||
<p class="hint">
|
||||
大文件分片上传时,未完成合并的会话保存在上传目录下的 <code>.chunk-uploads</code>。超过下方「保留时长」的目录会被定期删除。未在后台保存时,可使用环境变量
|
||||
<code>YH_CHUNK_UPLOAD_MAX_AGE_HOURS</code>、<code>YH_CHUNK_UPLOAD_SWEEP_MINUTES</code>(保存后台配置后优先生效)。
|
||||
</p>
|
||||
<el-form v-if="canEdit" :model="form" label-width="140px" style="max-width: 520px">
|
||||
<el-form-item label="保留时长(小时)">
|
||||
<el-input-number v-model="form.max_age_hours" :min="6" :max="336" :step="6" controls-position="right" />
|
||||
<span class="form-tip">6~336,默认 72;超过此时长未合并的会话视为非活动</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="扫描间隔(分钟)">
|
||||
<el-input-number v-model="form.sweep_minutes" :min="5" :max="1440" :step="5" controls-position="right" />
|
||||
<span class="form-tip">5~1440,默认 60;服务端按此频率检查是否需清扫</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="handleSave">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-else type="warning" title="无权限" description="需要「站点管理」权限。" :closable="false" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getChunkUploadCleanup, updateChunkUploadCleanup } from '../../api/admin'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
max_age_hours: 72,
|
||||
sweep_minutes: 60
|
||||
})
|
||||
|
||||
const canEdit = computed(() => authStore.hasPermission('site:manage'))
|
||||
|
||||
const fetchConfig = async () => {
|
||||
if (!canEdit.value) return
|
||||
try {
|
||||
const res = await getChunkUploadCleanup()
|
||||
form.max_age_hours = Number(res.max_age_hours) || 72
|
||||
form.sweep_minutes = Number(res.sweep_minutes) || 60
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '获取配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await updateChunkUploadCleanup({
|
||||
max_age_hours: form.max_age_hours,
|
||||
sweep_minutes: form.sweep_minutes
|
||||
})
|
||||
ElMessage.success('保存成功,新参数在下次清扫周期生效')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || e.message || '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchConfig)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chunk-upload-cleanup {
|
||||
padding: 0;
|
||||
}
|
||||
.hint {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
line-height: 1.65;
|
||||
max-width: 720px;
|
||||
}
|
||||
.form-tip {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
154
admin/src/views/settings/YuhengCloudAccountManage.vue
Normal file
154
admin/src/views/settings/YuhengCloudAccountManage.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="yh-cloud-accounts">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>宇恒云账号管理</span>
|
||||
</template>
|
||||
<p class="hint">
|
||||
调用云端
|
||||
<code>POST /register</code>(默认
|
||||
<code>http://www.cloud.yuxindazhineng.com:3001/register</code>,可通过环境变量
|
||||
<code>YH_CLOUD_REGISTER_URL</code> 覆盖)。成功后在 Mongo 集合
|
||||
<code>yuheng_cloud_register_records</code> 写入一条记录,<strong>仅保存账号与密码</strong>;邮箱仅用于提交云端。
|
||||
</p>
|
||||
|
||||
<el-form v-if="canEdit" :model="form" :rules="rules" ref="formRef" label-width="88px" class="add-form">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="云端 username" clearable style="max-width: 360px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="云端 password" show-password clearable style="max-width: 360px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="云端必填 email,不入库" clearable style="max-width: 360px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">提交注册</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-else type="warning" title="无权限" description="需要「宇恒云账号管理」权限,请在角色权限中为当前角色勾选。" :closable="false" />
|
||||
|
||||
<el-divider v-if="canEdit" />
|
||||
|
||||
<el-table v-if="canEdit" :data="list" v-loading="loading" stripe>
|
||||
<el-table-column prop="username" label="账号" min-width="140" />
|
||||
<el-table-column label="密码" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="pwd-mask">{{ row._showPwd ? row.password : '••••••••' }}</span>
|
||||
<el-button link type="primary" size="small" @click="row._showPwd = !row._showPwd">
|
||||
{{ row._showPwd ? '隐藏' : '显示' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="记录时间" width="200" />
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-if="canEdit"
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
style="margin-top: 16px"
|
||||
@current-change="fetchList"
|
||||
@size-change="fetchList"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createYuhengCloudAccount, listYuhengCloudAccounts } from '../../api/admin'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const canEdit = computed(() => authStore.hasPermission('yuheng_cloud:manage'))
|
||||
|
||||
const formRef = ref(null)
|
||||
const submitting = ref(false)
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
email: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
email: [{ required: true, message: '请输入邮箱(提交云端)', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
if (!canEdit.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listYuhengCloudAccounts({ page: page.value, page_size: pageSize.value })
|
||||
const rows = (res.list || []).map((r) => ({ ...r, _showPwd: false }))
|
||||
list.value = rows
|
||||
total.value = res.total ?? 0
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!formRef.value) return
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createYuhengCloudAccount({
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
email: form.email.trim()
|
||||
})
|
||||
ElMessage.success('云端注册成功,已写入本地记录')
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.email = ''
|
||||
formRef.value?.resetFields()
|
||||
fetchList()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || e.message || '提交失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (canEdit.value) fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.yh-cloud-accounts {
|
||||
padding: 0;
|
||||
}
|
||||
.hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
line-height: 1.65;
|
||||
max-width: 900px;
|
||||
}
|
||||
.add-form {
|
||||
max-width: 520px;
|
||||
}
|
||||
.pwd-mask {
|
||||
margin-right: 8px;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -43,7 +43,8 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getSites, getSiteAssets, uploadSiteAsset, deleteSiteAsset } from '../../api/admin'
|
||||
import { getSites, getSiteAssets, deleteSiteAsset } from '../../api/admin'
|
||||
import { uploadSiteAssetWithResume } from '../../utils/siteAssetResumableUpload'
|
||||
|
||||
const siteId = ref('')
|
||||
const sites = ref([])
|
||||
@@ -87,7 +88,7 @@ watch(siteId, fetchList)
|
||||
const beforeUpload = async (file) => {
|
||||
uploading.value = true
|
||||
try {
|
||||
await uploadSiteAsset(siteId.value, file)
|
||||
await uploadSiteAssetWithResume(siteId.value, file, {})
|
||||
ElMessage.success('上传成功')
|
||||
fetchList()
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user