为 Web UI 添加完整的登录验证系统,遵循 CQRS 架构模式。 主要功能: - 创建登录页面 UI (web/login.html) - 美观的渐变背景设计 - 密钥输入和验证 - 错误提示和加载状态 - 实现登录验证 (遵循 CQRS) - 新增 LoginQuery 和 LoginHandler (internal/api/handlers/auth_handlers.go) - 新增 AuthEndpoint (internal/api/endpoints/auth_endpoints.go) - 注册登录接口 /auth/login (无需授权) - 更新主页面 (web/index.html) - 添加登录状态检查 - 未登录显示提示信息 - 所有 API 请求自动携带 X-API-Key 头 - 添加退出登录功能 - 401 错误自动跳转登录页 - 更新路由配置 (cmd/server/main.go) - 添加 /auth/login 公开路由 - 注册登录处理器和端点 - 新增登录文档 (docs/LOGIN_GUIDE.md) - 完整的使用说明 - 技术实现细节 - API 接口说明 安全特性: - 密钥存储在 localStorage - 自动处理登录过期 - 支持主动退出登录 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
572 lines
28 KiB
HTML
572 lines
28 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>RustFS 高级文件管理系统</title>
|
|
<!-- Bootstrap CSS -->
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<!-- Vue 3 -->
|
|
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js"></script>
|
|
<!-- Axios -->
|
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
|
<!-- Font Awesome -->
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
|
<style>
|
|
body { background-color: #f8f9fa; font-family: "Microsoft YaHei", sans-serif; }
|
|
.card { border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); border: none; margin-bottom: 20px; }
|
|
.card-header { background-color: #fff; border-bottom: 1px solid #eee; font-weight: bold; padding: 15px 20px; }
|
|
.btn-primary { background-color: #0d6efd; border: none; }
|
|
.progress { height: 20px; border-radius: 10px; }
|
|
.file-icon { font-size: 1.2rem; margin-right: 10px; color: #6c757d; }
|
|
.action-btn { cursor: pointer; margin-right: 10px; }
|
|
.action-btn:hover { color: #0d6efd; }
|
|
[v-cloak] { display: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app" class="container py-4" v-cloak>
|
|
<!-- 未登录状态 -->
|
|
<div v-if="!isAuthenticated" class="text-center py-5">
|
|
<div class="alert alert-warning d-inline-block">
|
|
<i class="fas fa-lock fa-3x mb-3 d-block"></i>
|
|
<h4>需要登录</h4>
|
|
<p class="mb-3">请先登录以访问文件管理系统</p>
|
|
<a href="/web/login.html" class="btn btn-primary btn-lg">
|
|
<i class="fas fa-sign-in-alt me-2"></i>前往登录
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 已登录状态 -->
|
|
<div v-else>
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<h1><i class="fas fa-cloud-upload-alt text-primary me-2"></i>RustFS 文件管理系统</h1>
|
|
<div>
|
|
<a href="/swagger/index.html" target="_blank" class="btn btn-outline-secondary me-2">
|
|
<i class="fas fa-book me-1"></i>API 文档
|
|
</a>
|
|
<button class="btn btn-primary me-2" @click="showCreateBucketModal = true">
|
|
<i class="fas fa-plus me-1"></i>新建存储桶
|
|
</button>
|
|
<button class="btn btn-outline-danger" @click="logout">
|
|
<i class="fas fa-sign-out-alt me-1"></i>退出
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<!-- Sidebar: Buckets -->
|
|
<div class="col-md-3">
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
存储桶列表
|
|
<button class="btn btn-sm btn-link" @click="loadBuckets"><i class="fas fa-sync-alt"></i></button>
|
|
</div>
|
|
<div class="list-group list-group-flush">
|
|
<div v-for="bucket in buckets" :key="bucket"
|
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
|
:class="{ active: currentBucket === bucket }">
|
|
<a href="#" class="text-decoration-none flex-grow-1" :class="{ 'text-white': currentBucket === bucket }" @click.prevent="selectBucket(bucket)">
|
|
<i class="fas fa-box me-2"></i>{{ bucket }}
|
|
</a>
|
|
<span class="action-btn" :class="currentBucket === bucket ? 'text-white' : 'text-danger'" @click.stop="deleteBucket(bucket)" title="删除存储桶">
|
|
<i class="fas fa-trash-alt"></i>
|
|
</span>
|
|
</div>
|
|
<div v-if="buckets.length === 0" class="list-group-item text-muted text-center py-4">
|
|
暂无存储桶
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Content: File List & Upload -->
|
|
<div class="col-md-9">
|
|
<!-- Toolbar -->
|
|
<div class="card mb-3" v-if="currentBucket">
|
|
<div class="card-body py-3">
|
|
<div class="row g-3 align-items-center">
|
|
<div class="col-auto">
|
|
<label class="col-form-label fw-bold">{{ currentBucket }}</label>
|
|
</div>
|
|
<div class="col">
|
|
<div class="input-group">
|
|
<span class="input-group-text bg-white"><i class="fas fa-search"></i></span>
|
|
<input type="text" class="form-control" placeholder="搜索文件名..." v-model="filters.prefix" @keyup.enter="refreshFiles">
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<button class="btn btn-success" @click="triggerFileInput">
|
|
<i class="fas fa-upload me-1"></i>上传文件
|
|
</button>
|
|
<input type="file" ref="fileInput" class="d-none" @change="handleFileSelect">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File List Table -->
|
|
<div class="card" v-if="currentBucket">
|
|
<div class="card-body p-0">
|
|
<table class="table table-hover mb-0 align-middle">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th class="ps-4">文件名</th>
|
|
<th>大小</th>
|
|
<th>修改时间</th>
|
|
<th class="text-end pe-4">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="file in files" :key="file.Key">
|
|
<td class="ps-4">
|
|
<i :class="getFileIcon(file.Key)" class="file-icon"></i>
|
|
{{ file.Key }}
|
|
</td>
|
|
<td>{{ formatSize(file.Size) }}</td>
|
|
<td class="text-muted small">{{ formatDate(file.LastModified) }}</td>
|
|
<td class="text-end pe-4">
|
|
<span class="action-btn text-primary" @click="previewFile(file.Key)" title="预览">
|
|
<i class="fas fa-eye"></i>
|
|
</span>
|
|
<span class="action-btn text-success" @click="downloadFile(file.Key)" title="下载">
|
|
<i class="fas fa-download"></i>
|
|
</span>
|
|
<span class="action-btn text-danger" @click="deleteFile(file.Key)" title="删除">
|
|
<i class="fas fa-trash-alt"></i>
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="files.length === 0 && !loadingFiles">
|
|
<td colspan="4" class="text-center py-5 text-muted">
|
|
<i class="fas fa-folder-open fa-3x mb-3 d-block opacity-50"></i>
|
|
暂无文件
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<!-- Pagination -->
|
|
<div class="card-footer d-flex justify-content-between align-items-center" v-if="nextToken || pageHistory.length > 0">
|
|
<button class="btn btn-sm btn-outline-secondary" :disabled="pageHistory.length === 0" @click="prevPage">
|
|
<i class="fas fa-chevron-left me-1"></i>上一页
|
|
</button>
|
|
<span class="text-muted small">当前页数: {{ pageHistory.length + 1 }}</span>
|
|
<button class="btn btn-sm btn-outline-secondary" :disabled="!nextToken" @click="nextPage">
|
|
下一页<i class="fas fa-chevron-right ms-1"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="text-center py-5">
|
|
<i class="fas fa-arrow-left fa-3x text-muted mb-3 d-block opacity-25"></i>
|
|
<h4 class="text-muted">请选择一个存储桶</h4>
|
|
</div>
|
|
|
|
<!-- Upload Progress -->
|
|
<div class="card mt-3" v-if="uploads.length > 0">
|
|
<div class="card-header">上传任务队列</div>
|
|
<ul class="list-group list-group-flush">
|
|
<li class="list-group-item" v-for="upload in uploads" :key="upload.id">
|
|
<div class="d-flex justify-content-between mb-1">
|
|
<div>
|
|
<span class="fw-bold">{{ upload.file.name }}</span>
|
|
<span class="badge bg-secondary ms-2">{{ upload.status }}</span>
|
|
</div>
|
|
<span class="small text-muted">{{ upload.progress }}%</span>
|
|
</div>
|
|
<div class="progress">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
|
:class="getProgressBarClass(upload.status)"
|
|
role="progressbar"
|
|
:style="{ width: upload.progress + '%' }"></div>
|
|
</div>
|
|
<div class="mt-1 small text-danger" v-if="upload.error">{{ upload.error }}</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Bucket Modal -->
|
|
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5)" v-if="showCreateBucketModal">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">新建存储桶</h5>
|
|
<button type="button" class="btn-close" @click="showCreateBucketModal = false"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">存储桶名称</label>
|
|
<input type="text" class="form-control" v-model="newBucketName" placeholder="输入名称...">
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" @click="showCreateBucketModal = false">取消</button>
|
|
<button type="button" class="btn btn-primary" @click="createBucket">创建</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Preview Modal -->
|
|
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5)" v-if="previewUrl">
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">文件预览</h5>
|
|
<button type="button" class="btn-close" @click="previewUrl = null"></button>
|
|
</div>
|
|
<div class="modal-body text-center bg-light p-4">
|
|
<img v-if="isPreviewImage" :src="previewUrl" class="img-fluid" style="max-height: 80vh">
|
|
<video v-else-if="isPreviewVideo" :src="previewUrl" controls class="w-100" style="max-height: 80vh"></video>
|
|
<iframe v-else :src="previewUrl" class="w-100" style="height: 60vh; border:none"></iframe>
|
|
</div>
|
|
<div class="modal-footer justify-content-center">
|
|
<a :href="previewUrl" target="_blank" class="btn btn-outline-primary">
|
|
<i class="fas fa-external-link-alt me-1"></i>在新窗口打开
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const { createApp, ref, computed, onMounted } = Vue;
|
|
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
|
|
|
|
createApp({
|
|
setup() {
|
|
const isAuthenticated = ref(false);
|
|
const buckets = ref([]);
|
|
const currentBucket = ref(null);
|
|
const files = ref([]);
|
|
const loadingFiles = ref(false);
|
|
const nextToken = ref(null);
|
|
const pageHistory = ref([]);
|
|
const filters = ref({ prefix: '', maxKeys: 20 });
|
|
|
|
const showCreateBucketModal = ref(false);
|
|
const newBucketName = ref('');
|
|
|
|
const uploads = ref([]);
|
|
|
|
const previewUrl = ref(null);
|
|
const previewType = ref('');
|
|
|
|
// 检查登录状态
|
|
const checkAuth = () => {
|
|
const token = localStorage.getItem('rustfs_token');
|
|
if (token) {
|
|
isAuthenticated.value = true;
|
|
initApiClient(token);
|
|
} else {
|
|
isAuthenticated.value = false;
|
|
}
|
|
};
|
|
|
|
// 初始化 API 客户端
|
|
let api = null;
|
|
|
|
const initApiClient = (token) => {
|
|
api = axios.create({
|
|
baseURL: window.location.origin,
|
|
headers: {
|
|
'X-API-Key': token
|
|
}
|
|
});
|
|
};
|
|
|
|
// 退出登录
|
|
const logout = () => {
|
|
if (confirm('确定要退出登录吗?')) {
|
|
localStorage.removeItem('rustfs_token');
|
|
window.location.href = '/web/login.html';
|
|
}
|
|
};
|
|
|
|
// Load Buckets
|
|
const loadBuckets = async () => {
|
|
try {
|
|
const res = await api.get('/buckets');
|
|
buckets.value = res.data.buckets || [];
|
|
} catch (err) {
|
|
if (err.response?.status === 401) {
|
|
alert('登录已过期,请重新登录');
|
|
localStorage.removeItem('rustfs_token');
|
|
window.location.href = '/web/login.html';
|
|
} else {
|
|
alert('加载存储桶失败: ' + (err.response?.data?.error || err.message));
|
|
}
|
|
}
|
|
};
|
|
|
|
// Create Bucket
|
|
const createBucket = async () => {
|
|
if (!newBucketName.value) return alert('请输入名称');
|
|
try {
|
|
await api.post('/buckets', { bucket_name: newBucketName.value });
|
|
await loadBuckets();
|
|
showCreateBucketModal.value = false;
|
|
newBucketName.value = '';
|
|
} catch (err) {
|
|
alert('创建失败: ' + (err.response?.data?.error || err.message));
|
|
}
|
|
};
|
|
|
|
// Select Bucket
|
|
const selectBucket = (name) => {
|
|
currentBucket.value = name;
|
|
nextToken.value = null;
|
|
pageHistory.value = [];
|
|
loadFiles();
|
|
};
|
|
|
|
// Load Files
|
|
const currentToken = ref(null);
|
|
|
|
const loadFilesWrapped = async (token) => {
|
|
loadingFiles.value = true;
|
|
try {
|
|
const res = await api.get('/files/list', {
|
|
params: {
|
|
bucket_name: currentBucket.value,
|
|
prefix: filters.value.prefix,
|
|
max_keys: filters.value.maxKeys,
|
|
token: token
|
|
}
|
|
});
|
|
files.value = res.data.Files || [];
|
|
currentToken.value = token;
|
|
nextToken.value = res.data.NextContinuationToken;
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
loadingFiles.value = false;
|
|
}
|
|
}
|
|
|
|
const loadFiles = () => loadFilesWrapped(null);
|
|
|
|
const refreshFiles = () => {
|
|
nextToken.value = null;
|
|
pageHistory.value = [];
|
|
loadFiles();
|
|
};
|
|
|
|
const nextP = () => {
|
|
if(!nextToken.value) return;
|
|
pageHistory.value.push(currentToken.value);
|
|
loadFilesWrapped(nextToken.value);
|
|
}
|
|
|
|
const prevPage = () => {
|
|
if(pageHistory.value.length === 0) return;
|
|
const prevToken = pageHistory.value.pop();
|
|
loadFilesWrapped(prevToken);
|
|
}
|
|
|
|
// File Upload
|
|
const triggerFileInput = () => document.querySelector('input[type=file]').click();
|
|
|
|
const handleFileSelect = async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
e.target.value = '';
|
|
|
|
const uploadTask = {
|
|
id: Date.now(),
|
|
file: file,
|
|
progress: 0,
|
|
status: 'pending',
|
|
error: null,
|
|
uploadId: null,
|
|
parts: []
|
|
};
|
|
uploads.value.unshift(uploadTask);
|
|
|
|
processUpload(uploadTask);
|
|
};
|
|
|
|
const processUpload = async (task) => {
|
|
task.status = 'uploading';
|
|
const file = task.file;
|
|
const bucket = currentBucket.value;
|
|
const key = file.name;
|
|
|
|
try {
|
|
const initRes = await api.post('/files/multipart/init', {
|
|
bucket_name: bucket,
|
|
object_key: key
|
|
});
|
|
task.uploadId = initRes.data.upload_id;
|
|
|
|
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
|
|
const parts = [];
|
|
|
|
for (let i = 0; i < totalParts; i++) {
|
|
const start = i * CHUNK_SIZE;
|
|
const end = Math.min(start + CHUNK_SIZE, file.size);
|
|
const chunk = file.slice(start, end);
|
|
const partNumber = i + 1;
|
|
|
|
const formData = new FormData();
|
|
formData.append('bucket_name', bucket);
|
|
formData.append('object_key', key);
|
|
formData.append('upload_id', task.uploadId);
|
|
formData.append('part_number', partNumber);
|
|
formData.append('file', chunk);
|
|
|
|
let retries = 3;
|
|
let etag = null;
|
|
while(retries > 0) {
|
|
try {
|
|
const partRes = await api.put('/files/multipart/part', formData);
|
|
etag = partRes.data.etag;
|
|
break;
|
|
} catch(e) {
|
|
retries--;
|
|
if(retries === 0) throw e;
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
}
|
|
}
|
|
|
|
parts.push({ PartNumber: partNumber, ETag: etag });
|
|
task.progress = Math.round(((i + 1) / totalParts) * 100);
|
|
}
|
|
|
|
await api.post('/files/multipart/complete', {
|
|
bucket_name: bucket,
|
|
object_key: key,
|
|
upload_id: task.uploadId,
|
|
parts: parts
|
|
});
|
|
|
|
task.status = 'completed';
|
|
task.progress = 100;
|
|
setTimeout(() => refreshFiles(), 1000);
|
|
|
|
} catch (err) {
|
|
console.error(err);
|
|
task.status = 'failed';
|
|
task.error = err.response?.data?.error || err.message;
|
|
}
|
|
};
|
|
|
|
// Preview
|
|
const previewFile = async (key) => {
|
|
try {
|
|
const res = await api.get('/files/preview', {
|
|
params: { bucket_name: currentBucket.value, object_key: key }
|
|
});
|
|
previewUrl.value = res.data.url;
|
|
|
|
const ext = key.split('.').pop().toLowerCase();
|
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
|
|
previewType.value = 'image';
|
|
} else if (['mp4', 'webm', 'ogg'].includes(ext)) {
|
|
previewType.value = 'video';
|
|
} else {
|
|
previewType.value = 'other';
|
|
}
|
|
} catch (err) {
|
|
alert('无法获取预览链接');
|
|
}
|
|
};
|
|
|
|
const isPreviewImage = computed(() => previewType.value === 'image');
|
|
const isPreviewVideo = computed(() => previewType.value === 'video');
|
|
|
|
// Download
|
|
const downloadFile = (key) => {
|
|
const url = `${window.location.origin}/files/download?bucket_name=${encodeURIComponent(currentBucket.value)}&object_key=${encodeURIComponent(key)}`;
|
|
window.open(url, '_blank');
|
|
};
|
|
|
|
// Delete File
|
|
const deleteFile = async (key) => {
|
|
if (!confirm(`确定要删除文件 "${key}" 吗?此操作不可恢复!`)) return;
|
|
try {
|
|
await api.delete('/files/delete', {
|
|
data: { bucket_name: currentBucket.value, object_key: key }
|
|
});
|
|
loadFilesWrapped(currentToken.value);
|
|
} catch (err) {
|
|
alert('删除失败: ' + (err.response?.data?.error || err.message));
|
|
}
|
|
};
|
|
|
|
// Delete Bucket
|
|
const deleteBucket = async (bucketName) => {
|
|
if (!confirm(`确定要删除存储桶 "${bucketName}" 吗?\n注意:存储桶必须为空才能删除!`)) return;
|
|
try {
|
|
await api.delete('/buckets', {
|
|
data: { bucket_name: bucketName }
|
|
});
|
|
if (currentBucket.value === bucketName) {
|
|
currentBucket.value = null;
|
|
files.value = [];
|
|
}
|
|
await loadBuckets();
|
|
} catch (err) {
|
|
alert('删除存储桶失败: ' + (err.response?.data?.error || err.message));
|
|
}
|
|
};
|
|
|
|
// Utils
|
|
const formatSize = (bytes) => {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const formatDate = (dateStr) => {
|
|
return new Date(dateStr).toLocaleString('zh-CN');
|
|
};
|
|
|
|
const getFileIcon = (filename) => {
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
if (['jpg', 'png', 'gif'].includes(ext)) return 'fas fa-file-image text-primary';
|
|
if (['pdf', 'doc', 'docx'].includes(ext)) return 'fas fa-file-pdf text-danger';
|
|
if (['mp4', 'avi'].includes(ext)) return 'fas fa-file-video text-success';
|
|
if (['zip', 'rar'].includes(ext)) return 'fas fa-file-archive text-warning';
|
|
return 'fas fa-file text-secondary';
|
|
};
|
|
|
|
const getProgressBarClass = (status) => {
|
|
if(status === 'completed') return 'bg-success';
|
|
if(status === 'failed') return 'bg-danger';
|
|
return 'bg-primary';
|
|
}
|
|
|
|
onMounted(() => {
|
|
checkAuth();
|
|
if (isAuthenticated.value) {
|
|
loadBuckets();
|
|
}
|
|
});
|
|
|
|
return {
|
|
isAuthenticated,
|
|
buckets, currentBucket, files, loadingFiles, nextToken, pageHistory, filters,
|
|
showCreateBucketModal, newBucketName, uploads, previewUrl, isPreviewImage, isPreviewVideo,
|
|
logout,
|
|
loadBuckets, createBucket, selectBucket, deleteBucket, refreshFiles,
|
|
nextPage: nextP, prevPage,
|
|
triggerFileInput, handleFileSelect,
|
|
previewFile, downloadFile, deleteFile,
|
|
formatSize, formatDate, getFileIcon, getProgressBarClass
|
|
};
|
|
}
|
|
}).mount('#app');
|
|
</script>
|
|
</body>
|
|
</html>
|