添加缓存逻辑;

完成稿件管理逻辑;
新增博客编辑器;
新增博客;
新增请求测试小工具;
修改sweetheart样式;
打包用户头像组件;
新增导航栏隐藏功能;
新增3D打枪小游戏;
This commit is contained in:
Guarp 2025-03-13 23:52:31 +08:00
parent add84cc1c2
commit af2ba6b1a3
46 changed files with 4267 additions and 1017 deletions

1716
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,9 +9,11 @@
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.9",
"element-plus": "^2.9.6",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5",
"marked": "^15.0.7",
@ -23,6 +25,9 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"sass": "^1.85.1",
"unplugin-auto-import": "^19.1.1",
"unplugin-vue-components": "^28.4.1",
"vite": "^6.1.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

@ -0,0 +1,395 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D第一人称射击游戏</title>
<style>
body { margin: 0; overflow: hidden; }
#crosshair {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background-color: red;
border-radius: 50%;
}
.ui { font-family: Arial, sans-serif; color: white; font-size: 20px; position: absolute; }
</style>
</head>
<body>
<!-- 准星 -->
<div id="crosshair"></div>
<!-- 受伤闪烁效果 -->
<div id="damageOverlay" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(255, 0, 0, 0); pointer-events: none;"></div>
<!-- 游戏结束弹窗 -->
<div id="gameOverModal" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.8); color: white; padding: 20px; border-radius: 10px; text-align: center; display: none;">
<h2>游戏结束</h2>
<p id="finalScore"></p>
<button id="restartButton" style="padding: 10px 20px; font-size: 16px; cursor: pointer;">重新开始</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.9.1/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/simplex-noise/2.4.0/simplex-noise.min.js"></script>
<script>
let isGamePaused = false;
// 初始化场景
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 1.6, 0); // 玩家高度1.6米
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 添加光照
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight.position.set(1, 1, 1);
scene.add(directionalLight);
// 视角控制(重新设计)
let yaw = 0; // 水平旋转角度(左右)
let pitch = 0; // 垂直旋转角度(上下)
const sensitivity = 0.002; // 鼠标灵敏度
document.addEventListener('click', () => {
renderer.domElement.requestPointerLock();
});
document.addEventListener('mousemove', (event) => {
if (document.pointerLockElement === renderer.domElement) {
const movementX = event.movementX || 0;
const movementY = event.movementY || 0;
// 水平旋转(向左移动鼠标镜头向左)
yaw -= movementX * sensitivity;
// 垂直旋转(向上移动鼠标镜头向上)
pitch -= movementY * sensitivity;
pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, pitch)); // 限制垂直角度
// 更新相机旋转
camera.rotation.order = 'YXZ'; // 先Y水平后X垂直
camera.rotation.set(pitch, yaw, 0);
}
});
// WASD移动控制
const keys = {};
document.addEventListener('keydown', (event) => keys[event.key.toLowerCase()] = true);
document.addEventListener('keyup', (event) => keys[event.key.toLowerCase()] = false);
// 子弹管理
let bullets = 10;
const maxBullets = 10;
const bulletDisplay = document.createElement('div');
bulletDisplay.className = 'ui';
bulletDisplay.style.bottom = '10px';
bulletDisplay.style.right = '10px';
bulletDisplay.innerText = `子弹: ${bullets}`;
document.body.appendChild(bulletDisplay);
// 生命值
let health = 100;
const healthDisplay = document.createElement('div');
healthDisplay.className = 'ui';
healthDisplay.style.bottom = '10px';
healthDisplay.style.left = '10px';
healthDisplay.innerText = `生命值: ${health}`;
document.body.appendChild(healthDisplay);
function deductHealth(amount) {
if (isGamePaused) return; // 如果已暂停,不再扣血
health -= amount;
healthDisplay.innerText = `生命值: ${health}`;
// 触发红色闪烁效果
const damageOverlay = document.getElementById('damageOverlay');
gsap.fromTo(
damageOverlay,
{ backgroundColor: 'rgba(255, 0, 0, 0.5)' },
{
backgroundColor: 'rgba(255, 0, 0, 0)',
duration: 0.5,
ease: 'power1.out'
}
);
if (health <= 0) {
endGame();
}
}
// 枪械展示
const gunTexture = new THREE.TextureLoader().load('./image/gun.png'); // 替换为实际枪械图片URL
const gunMaterial = new THREE.MeshBasicMaterial({ map: gunTexture, transparent: true });
const gunGeometry = new THREE.PlaneGeometry(1, 1);
const gun = new THREE.Mesh(gunGeometry, gunMaterial);
gun.position.set(0.5, -0.5, -1);
camera.add(gun);
scene.add(camera);
// 开枪函数
function shoot() {
if (bullets > 0) {
bullets--;
bulletDisplay.innerText = `子弹: ${bullets}`;
gsap.to(gun.position, { z: -1.2, duration: 0.05, yoyo: true, repeat: 1 });
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(0, 0), camera);
const intersects = raycaster.intersectObjects(enemies.map(e => e.sprite));
if (intersects.length > 0) {
const enemy = intersects[0].object;
const hitDirection = new THREE.Vector3()
.subVectors(enemy.position, camera.position)
.normalize();
// 添加击飞效果
gsap.to(enemy.position, {
x: enemy.position.x + hitDirection.x * 5, // 沿射击方向飞5个单位
y: enemy.position.y + 2, // 向上飞2个单位
z: enemy.position.z + hitDirection.z * 5,
duration: 0.3, // 飞行时间0.3秒
ease: "power2.out", // 减速效果
onComplete: () => {
scene.remove(enemy); // 动画完成后移除敌人
enemies = enemies.filter(e => e.sprite !== enemy);
}
});
// 同时淡出敌人
gsap.to(enemy.material, {
opacity: 0,
duration: 0.3,
onComplete: () => {
enemy.material.opacity = 1; // 重置透明度以备复用
}
});
score += 20;
timerDisplay.innerText = `时间: ${timeLeft} | 分数: ${score}`;
}
}
}
document.addEventListener('click', shoot);
document.addEventListener('keydown', (event) => {
if (event.key === 'r' && bullets < maxBullets) {
bullets = maxBullets;
bulletDisplay.innerText = `子弹: ${bullets}`;
}
});
// 地形生成
const noise = new SimplexNoise();
const terrainSize = 100;
const terrainDetail = 0.1;
const terrainHeight = 1; // 平缓地形
const geometry = new THREE.PlaneGeometry(terrainSize, terrainSize, 100, 100);
geometry.rotateX(-Math.PI / 2);
const vertices = geometry.attributes.position.array;
for (let i = 0; i < vertices.length; i += 3) {
const x = vertices[i];
const z = vertices[i + 2];
const y = noise.noise2D(x * terrainDetail, z * terrainDetail) * terrainHeight;
vertices[i + 1] = y;
}
geometry.computeVertexNormals();
const terrainMaterial = new THREE.MeshStandardMaterial({ color: 0x888888 });
const terrain = new THREE.Mesh(geometry, terrainMaterial);
scene.add(terrain);
// 射线投射器
const raycaster = new THREE.Raycaster();
raycaster.ray.direction.set(0, -1, 0);
// 敌人生成(高度贴合地表)
let enemies = [];
class Enemy {
constructor() {
const enemyTextures = [
'./image/1.png',
'./image/2.png',
];
const texture = new THREE.TextureLoader().load(enemyTextures[Math.floor(Math.random() * enemyTextures.length)]);
const material = new THREE.SpriteMaterial({ map: texture });
this.sprite = new THREE.Sprite(material);
this.sprite.scale.set(2, 2, 1);
// 随机选择地图边缘0: 上, 1: 下, 2: 左, 3: 右)
const edge = Math.floor(Math.random() * 4);
const halfSize = terrainSize / 2; // terrainSize = 100, halfSize = 50
switch (edge) {
case 0: // 上边缘 (z = 50)
this.sprite.position.set(
Math.random() * terrainSize - halfSize, // x: [-50, 50]
10, // y: 10
halfSize // z: 50
);
break;
case 1: // 下边缘 (z = -50)
this.sprite.position.set(
Math.random() * terrainSize - halfSize, // x: [-50, 50]
10, // y: 10
-halfSize // z: -50
);
break;
case 2: // 左边缘 (x = -50)
this.sprite.position.set(
-halfSize, // x: -50
10, // y: 10
Math.random() * terrainSize - halfSize // z: [-50, 50]
);
break;
case 3: // 右边缘 (x = 50)
this.sprite.position.set(
halfSize, // x: 50
10, // y: 10
Math.random() * terrainSize - halfSize // z: [-50, 50]
);
break;
}
scene.add(this.sprite);
this.sprite.material.opacity = 0;
gsap.to(this.sprite.material, { opacity: 1, duration: 1 });
}
update() {
this.sprite.lookAt(camera.position);
const direction = new THREE.Vector3().subVectors(camera.position, this.sprite.position).normalize();
this.sprite.position.add(direction.multiplyScalar(0.05));
// 贴合地表高度
raycaster.ray.origin.copy(this.sprite.position);
raycaster.ray.origin.y = 10; // 从上方检测
const intersects = raycaster.intersectObject(terrain);
if (intersects.length > 0) {
this.sprite.position.y = intersects[0].point.y + 1; // 高度为地表+1
}
if (this.sprite.position.distanceTo(camera.position) < 1) {
deductHealth(15);
scene.remove(this.sprite);
enemies = enemies.filter(e => e !== this);
}
}
}
setInterval(() => {
if (enemies.length < 10) {
const newEnemy = new Enemy();
enemies.push(newEnemy);
}
}, 2000); // 每2秒生成一个敌人
// 计时与得分
let timeLeft = 150;
let score = 0;
const timerDisplay = document.createElement('div');
timerDisplay.className = 'ui';
timerDisplay.style.top = '10px';
timerDisplay.style.left = '10px';
timerDisplay.innerText = `时间: ${timeLeft} | 分数: ${score}`;
document.body.appendChild(timerDisplay);
setInterval(() => {
if (isGamePaused) return; // 如果暂停,不更新计时器
timeLeft--;
score++;
timerDisplay.innerText = `时间: ${timeLeft} | 分数: ${score}`;
if (timeLeft <= 0) {
endGame();
}
}, 1000);
// 新增游戏结束函数
function endGame() {
isGamePaused = true;
document.exitPointerLock();
// 显示弹窗
const modal = document.getElementById('gameOverModal');
const finalScore = document.getElementById('finalScore');
finalScore.innerText = `最终得分: ${score}`;
modal.style.display = 'block';
}
// 新增重新开始函数
function restartGame() {
// 重置游戏状态
isGamePaused = false;
health = 100;
bullets = maxBullets;
timeLeft = 150;
score = 0;
enemies.forEach(enemy => scene.remove(enemy.sprite)); // 移除所有敌人
enemies = [];
// 更新UI
healthDisplay.innerText = `生命值: ${health}`;
bulletDisplay.innerText = `子弹: ${bullets}`;
timerDisplay.innerText = `时间: ${timeLeft} | 分数: ${score}`;
// 隐藏弹窗
document.getElementById('gameOverModal').style.display = 'none';
// 重新启动动画循环
animate();
}
// 为重新开始按钮添加事件监听
document.getElementById('restartButton').addEventListener('click', restartGame);
// 天空贴图
const skyboxLoader = new THREE.CubeTextureLoader();
const skyboxTexture = skyboxLoader.load([
'https://i.imgur.com/px.jpg', 'https://i.imgur.com/nx.jpg',
'https://i.imgur.com/py.jpg', 'https://i.imgur.com/ny.jpg',
'https://i.imgur.com/pz.jpg', 'https://i.imgur.com/nz.jpg'
]); // 替换为实际天空贴图URL
scene.background = skyboxTexture;
// 动画循环
function animate() {
if (isGamePaused) return; // 如果暂停则跳出循环
requestAnimationFrame(animate);
// WASD移动
const speed = 0.1;
const frontVector = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
const rightVector = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
if (keys['w']) camera.position.add(frontVector.multiplyScalar(speed));
if (keys['s']) camera.position.add(frontVector.multiplyScalar(-speed));
if (keys['a']) camera.position.add(rightVector.multiplyScalar(-speed));
if (keys['d']) camera.position.add(rightVector.multiplyScalar(speed));
// 贴合地形高度
raycaster.ray.origin.copy(camera.position);
raycaster.ray.origin.y += 1.6;
const intersects = raycaster.intersectObject(terrain);
if (intersects.length > 0) {
camera.position.y = intersects[0].point.y + 1.6;
}
// 更新敌人(仅在游戏未暂停时)
if (!isGamePaused) {
enemies.forEach(enemy => enemy.update());
}
renderer.render(scene, camera);
}
animate();
// 自适应窗口大小
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>

View File

@ -59,7 +59,7 @@ export default class AuthService {
throw new Error('error')
}
if (store.state.token) {
store.commit('setUserInfo', info.info)
store.commit('setUserInfo', info.data)
}
} catch (e) {

View File

@ -1,6 +1,6 @@
<template>
<NavBar/>
<div class="main-container">
<NavBar v-if="store.state.navBar.display"/>
<div class="main-container" :style="{marginTop: store.state.navBar.display ? '60px' : '0'}">
<router-view />
</div>
@ -18,26 +18,28 @@ const store = useStore()
// body
const updateGlobalTheme = (theme) => {
if (!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.body.classList.add('theme-light')
return;
}
// if (!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
// document.body.classList.add('theme-light')
// document.documentElement.classList.remove('dark');
// return;
// }
if (theme === 'light') {
document.body.classList.add('theme-light')
document.body.classList.add('theme-light');
document.documentElement.classList.remove('dark');
} else {
document.body.classList.remove('theme-light')
document.body.classList.remove('theme-light');
document.documentElement.classList.add('dark');
}
}
// store
onMounted(async () => {
updateGlobalTheme(store.state.theme);
if (await AuthService.getToken()) {
AuthService.setSelfInfo();
await AuthService.setSelfInfo();
} else {
AuthService.logout();
}
updateGlobalTheme(store.state.theme);
})
// Vuex
@ -57,7 +59,6 @@ watch(
.main-container {
flex: 1;
flex-direction: column;
margin-top: 60px;
display: flex;
width: 100%;
align-items: center;

View File

@ -1,5 +1,11 @@
<script setup>
import { defineProps } from 'vue'
import {defineProps} from 'vue'
import DefaultCover from "./DefaultCover.vue";
import router from "../router/index.js";
import store from "../store/index.js";
import {blogImage} from "../utils/imageResource.js";
import swal from "../utils/sweetalert.js";
import api from "../utils/axios.js";
const props = defineProps({
cover: {
@ -21,14 +27,36 @@ const props = defineProps({
isDraft: {
type: Boolean,
default: false
},
id: {
type: Number,
required: true
}
})
const emit = defineEmits(['refresh']);
//
function onEdit() {
console.log('编辑')
store.state.editStore.currentBlogId = props.id;
router.push('/editor');
}
async function onDelete() {
const result = await swal.window('info', '确定删除吗?', '此操作不可撤销', '确实', '取消');
try {
if (result.isConfirmed) {
const response = await api.delete(`/blogs/${props.id}`);
if (response.code === 0) {
swal.tip('success', '删除成功');
emit('refresh', 1);
return;
}
swal.tip('error', '删除失败');
}
} catch {
swal.tip('error', '网络错误');
}
}
function onPermission() {
console.log('权限')
}
@ -38,7 +66,8 @@ function onPermission() {
<div class="work-piece">
<!-- 左侧博客封面 + 标题 -->
<div class="piece-left">
<img :src="cover" alt="封面" class="cover-image" />
<!-- <img :src="cover" alt="封面" class="cover-image" />-->
<DefaultCover :imageSrc="blogImage(cover)" :text="title" size="60" class="cover-image"/>
<div class="title-text">{{ title }}</div>
</div>
@ -52,14 +81,16 @@ function onPermission() {
<!-- 下方操作 -->
<div class="actions">
<span class="action-btn" @click="onEdit">编辑</span>
<router-link v-if="!isDraft" class="action-btn" :to="`/blog/${id}`" target="_blank">查看</router-link>
<span class="action-btn" @click="onDelete">删除</span>
<!-- 草稿时不显示权限 -->
<span
v-if="!isDraft"
class="action-btn"
@click="onPermission"
>
权限
</span>
<!-- <span-->
<!-- v-if="!isDraft"-->
<!-- class="action-btn"-->
<!-- @click="onPermission"-->
<!-- >-->
<!-- 权限-->
<!-- </span>-->
</div>
</div>
</div>
@ -83,19 +114,21 @@ function onPermission() {
}
/* 左侧 */
.piece-left {
display: flex;
align-items: center;
gap: 12px;
}
.cover-image {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
overflow: hidden;
}
.title-text {
font-size: 16px;
font-weight: bold;
@ -108,20 +141,24 @@ function onPermission() {
align-items: flex-end;
gap: 8px;
}
.times {
opacity: 0.5;
font-size: small;
text-align: right;
}
.actions {
display: flex;
gap: 16px;
}
.action-btn {
font-size: 14px;
opacity: 0.6;
cursor: pointer;
}
.action-btn:hover {
opacity: 0.5;
}

View File

@ -1,5 +1,7 @@
<script setup>
import {setRandomBGCL} from "../utils/randomBGCL.js";
import {blogImage} from "../utils/imageResource.js";
import DefaultCover from "./DefaultCover.vue";
defineProps({
blog: Object,
@ -7,17 +9,14 @@ defineProps({
</script>
<template>
<router-link :to="'/blog/'+blog.id">
<router-link :to="'/blog/'+blog.id" target="_blank">
<div class="blog-box" :style="{background: setRandomBGCL(blog.title)}">
<div class="image-container" :style="{background: setRandomBGCL(blog.title)}">
<div class="cover-character">{{ blog.title[0] }}</div>
<img v-if="blog.image" :src="blog.image" alt="Blog image" class="blog-image" />
</div>
<DefaultCover :imageSrc="blogImage(blog.cover)" :text="blog.title"/>
<div class="blog-details">
<h3>{{ blog.title }}</h3>
<p class="author">By uid{{ blog.poster }}</p>
<p class="author">{{ blog.poster_name }}</p>
<p class="time"> {{ blog.post_date }}</p>
<div class="tags">
<span v-for="tag in blog.tags" :key="tag" class="tag">{{ tag }}</span>
@ -39,26 +38,11 @@ defineProps({
cursor: pointer;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.image-container {
width: 100%;
height: 100%;
}
.image-container .cover-character {
width: 100%;
height: 100%;
font-size: 200px;;
font-family: 'huangkaihua', Tahoma, Geneva, Verdana, sans-serif;
color: gray;
mix-blend-mode: difference;
}
.blog-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.blog-details {
z-index: 10;
background: #111111;
width: 100%;
padding: 10px;

View File

@ -0,0 +1,148 @@
<template>
<div class="comments-container">
<!-- 评论列表 -->
<div class="comment-list">
<div v-for="(comment, index) in comments" :key="comment.id" class="comment-item">
<el-divider v-if="index !== 0"/>
<Blog_rootComment :comment="comment"/>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading">加载中...</div>
<div v-if="isEnd && comments.length > 0" class="no-more">没有更多评论了</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="!loading && comments.length === 0" class="empty">暂无评论</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import {getInfoWithPages} from "../utils/getInfoWithPages.js";
import Blog_rootComment from "./Blog_rootComment.vue";
//
const props = defineProps({
blogId: {
type: Number,
required: true
},
scrollContainer: {
type: Object,
default: null
}
})
//
const comments = ref([]) //
const amount = ref(0) //
const page = ref(1) //
const pageSize = 20 //
const loading = ref(false) //
const isEnd = ref(false) //
const error = ref(null) //
const scrollContainer = ref(null) //
function addCommentToFront(newComments) {
comments.value.unshift(newComments); //
}
// defineExpose
defineExpose({ addCommentToFront });
const fetchComments = async (pageNum) => {
try {
// API
const response = await getInfoWithPages(`/blogs/${props.blogId}/comments`, pageNum, pageSize, {sort: 'DESC'})
return {
list: response.data || [],
total: response.amount || 0
}
} catch (err) {
throw new Error('获取评论失败: ' + err.message)
}
}
//
const loadComments = async () => {
if (loading.value || isEnd.value) return
loading.value = true
error.value = null
try {
const { list, total } = await fetchComments(page.value)
amount.value = total;
comments.value = [...comments.value, ...list]
page.value++
//
if (comments.value.length >= total || list.length < pageSize) {
isEnd.value = true
}
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
//
const handleScroll = (e) => {
const container = e.target
const { scrollTop, scrollHeight, clientHeight } = container
if (scrollHeight - scrollTop - clientHeight <= 100 && !loading.value) {
loadComments()
}
}
onMounted(() => {
console.log('props.scrollContainer', props.scrollContainer)
loadComments()
if (props.scrollContainer) {
props.scrollContainer.addEventListener('scroll', handleScroll)
}
})
onUnmounted(() => {
if (props.scrollContainer) {
props.scrollContainer.removeEventListener('scroll', handleScroll)
}
})
</script>
<style scoped>
.comments-container {
width: 100%;
margin: 0 auto;
}
.comment-list {
overflow-y: auto;
padding: 10px;
border-radius: 4px;
}
.el-divider {
width: calc(100% - 80px);
margin-left: 40px;
margin-right: 40px;
}
.loading,
.no-more,
.error,
.empty {
text-align: center;
padding: 10px;
color: rgba(126, 126, 126, 0.55);
}
.error {
color: #ff0000;
}
</style>

View File

@ -0,0 +1,122 @@
<template>
<div class="comment-container">
<!-- 用户头像 -->
<Profile_display :id="comment.profile" size="40"/>
<!-- 评论内容 -->
<div class="comment-content">
<div class="user-info">
<span class="username">{{ comment.poster_name }}</span>
</div>
<div class="comment-text">
{{ comment.content }}
</div>
<div class="comment-meta">
<span class="date">{{ formattedTime }}</span>
<Like_button
:active="comment.liked"
:amount="comment.likes"
:toggleFunc="clickLikeBtn"
direction="h"
iconSize="16"
fontSize="12"
/>
</div>
</div>
</div>
</template>
<script setup>
import {computed, ref} from 'vue';
import Like_button from './Like_button.vue';
import Profile_display from "./Profile_display.vue";
import {formatGMTToLocal, timeDifference} from "../utils/formatTime.js";
import api from "../utils/axios.js";
import swal from "../utils/sweetalert.js";
// props
const props = defineProps({
comment: {
type: Object,
required: true,
}
});
//
const formattedTime = computed(() => {
const localTime = formatGMTToLocal(props.comment.date, 3);
return localTime;
})
//
const clickLikeBtn = async (isLiked) => {
try {
if (isLiked) {
const response = await api.post(`/comments/${props.comment.id}/like`);
if (response.code !== 0) {
swal.tip('error', '点赞失败');
}
} else {
const response = await api.delete(`/comments/${props.comment.id}/like`);
if (response.code !== 0) {
swal.tip('error', '取消点赞失败');
}
}
} catch {
swal.tip('error', '网络错误');
}
}
</script>
<style scoped>
.comment-container {
display: flex;
align-items: flex-start;
padding: 10px;
color: #d9d9d9;
transition: all 0.3s ease;
}
.theme-light .comment-container {
color: #262626;
}
.comment-content {
margin-left: 10px;
flex: 1;
}
.user-info {
font-size: 14px;
//color: #333;
}
.username {
font-size: 14px;
margin-right: 10px;
}
.comment-text {
margin: 5px 0;
}
.comment-meta {
position: relative;
display: flex;
align-items: center;
font-size: 12px;
//color: #999;
}
.date {
margin-right: 15px;
font-size: 13px;
opacity: 0.8;
}
.like {
position: absolute;
margin-left: 130px;
}
</style>

View File

@ -0,0 +1,52 @@
<script setup>
import { ref } from "vue";
import { setRandomBGCL } from "../utils/randomBGCL.js";
defineProps({
text: {
type: String,
required: true
},
imageSrc: {
type: String,
required: true
},
size: {
type: Number,
required: false,
default: 200
}
});
const imageLoaded = ref(false);
</script>
<template>
<div class="image-container" :style="{background: setRandomBGCL(text), height: size + 'px'}">
<img :src="imageSrc" alt="image" class="image"
:style="{height: size + 'px', display: imageLoaded ? 'block' : 'none'}"
@load="imageLoaded = true"
@error="imageLoaded = false" />
<div v-if="!imageLoaded" class="cover-character" :style="{fontSize: size + 'px'}">
{{ text[0] }}
</div>
</div>
</template>
<style scoped>
.image-container {
width: 100%;
}
.image-container .cover-character {
width: 100%;
height: 100%;
font-family: 'huangkaihua', Tahoma, Geneva, Verdana, sans-serif;
color: gray;
mix-blend-mode: difference;
}
.image {
width: 100%;
object-fit: cover;
}
</style>

View File

@ -10,6 +10,7 @@ defineProps({
<img v-if="demo.image" :src="demo.image" alt="Demo image" class="demo-image" />
<div class="demo-details">
<h3>{{ demo.name }}</h3>
<p class="description" v-if="demo.description">{{ demo.description }}</p>
<p class="author">{{ demo.author.join("、") }}</p>
<p class="date">{{ demo.date }}</p>
<div class="tags">
@ -93,6 +94,11 @@ h3 {
opacity: 0.5;
transition: opacity 0.3s ease;
}
.description {
font-size: 14px;
opacity: 0.5;
transition: opacity 0.3s ease;
}
.demo-box:hover .author {
opacity: 1;
}
@ -151,7 +157,6 @@ h3 {
.demo-box.no-image .demo-details {
gap: 15px;
align-items: center;
}

View File

@ -1,7 +1,12 @@
<script setup>
import {ref, watch} from "vue";
import store from "../store/index.js";
const props = defineProps({
disable: {
type: Boolean,
default: !store.getters.hasUserInfo
},
active: {
type: Boolean,
required: true
@ -10,9 +15,20 @@ const props = defineProps({
type: Number,
required: true
},
toggleFunc: {
toggleFunc: { // : true; : false
type: Function,
required: false
},
direction: {
type: String,
default: 'h'
},
iconSize: {
type: Number,
default: 16
},
fontSize: {
type: Number,
default: 16
}
});
@ -39,6 +55,10 @@ function debounce(func, delay) {
const clickFunc = () => {
setTimeout(async () => {
if (props.disable) {
return;
}
if (activeTemp === null) {
activeTemp = alterActive.value;
}
@ -68,23 +88,23 @@ watch(() => props.amount, () => {
</script>
<template>
<label class="like">
<button @click="clickFunc">
<label class="like" :class="{'vertical': direction.includes('v'), 'disable': disable}">
<span @click="clickFunc" :disabled="disable">
<svg v-if="!(alterActive)" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width="16" height="16">
:width="iconSize" :height="iconSize">
<path
d="M9.283433333333333 2.0303066666666663C9.095466666666667 2.0083933333333333 8.921333333333333 2.09014 8.828166666666666 2.1991199999999997C8.424633333333333 2.6711333333333336 8.332133333333333 3.3649466666666665 8.029333333333334 3.9012466666666663C7.630633333333333 4.607453333333333 7.258833333333333 5.034486666666666 6.800866666666666 5.436006666666666C6.42382 5.7665733333333336 6.042199999999999 5.987959999999999 5.666666666666666 6.09112L5.666666666666666 13.1497C6.19062 13.1611 6.751966666666666 13.168333333333333 7.333333333333333 13.168333333333333C8.831233333333333 13.168333333333333 10.1019 13.120766666666665 10.958166666666665 13.076699999999999C11.565133333333332 13.045433333333332 12.091966666666666 12.7451 12.366466666666668 12.256733333333333C12.7516 11.571599999999998 13.2264 10.5669 13.514166666666664 9.3835C13.7823 8.2808 13.904599999999999 7.374333333333333 13.959466666666666 6.734999999999999C13.984933333333332 6.438646666666667 13.750433333333334 6.166686666666667 13.386666666666665 6.166686666666667L10.065133333333332 6.166686666666667C9.898433333333333 6.166686666666667 9.742666666666667 6.08362 9.649833333333333 5.945166666666666C9.536066666666667 5.775493333333333 9.560033333333333 5.5828533333333334 9.6312 5.403346666666666C9.783966666666666 5.013846666666666 9.983933333333333 4.432846666666666 10.062766666666667 3.90454C10.1406 3.3830066666666667 10.121599999999999 2.9639466666666667 9.917133333333332 2.57626C9.697399999999998 2.1596933333333332 9.448266666666665 2.0495266666666665 9.283433333333333 2.0303066666666663zM10.773433333333333 5.166686666666666L13.386666666666665 5.166686666666666C14.269133333333333 5.166686666666666 15.036999999999999 5.875273333333333 14.9558 6.8206C14.897 7.505533333333333 14.767199999999999 8.462733333333333 14.485833333333334 9.6198C14.170333333333334 10.917200000000001 13.6532 12.008466666666665 13.238166666666666 12.746766666666666C12.7729 13.574433333333333 11.910266666666667 14.029 11.009566666666666 14.075366666666667C10.14 14.120166666666666 8.851766666666666 14.168333333333333 7.333333333333333 14.168333333333333C5.862206666666666 14.168333333333333 4.51776 14.1231 3.565173333333333 14.079633333333334C2.4932333333333334 14.030733333333332 1.5939999999999999 13.234466666666666 1.4786599999999999 12.143466666666665C1.4028 11.426066666666665 1.3333333333333333 10.4978 1.3333333333333333 9.501666666666665C1.3333333333333333 8.588966666666666 1.3916466666666667 7.761233333333333 1.4598999999999998 7.104466666666667C1.5791666666666666 5.95696 2.5641 5.166686666666666 3.671693333333333 5.166686666666666L5.166666666666666 5.166686666666666C5.3793066666666665 5.166686666666666 5.709213333333333 5.063186666666667 6.141613333333333 4.68408C6.516733333333333 4.355193333333333 6.816366666666667 4.015666666666666 7.158533333333333 3.409613333333333C7.5023 2.8007333333333335 7.6041 2.0920066666666663 8.068066666666667 1.54932C8.372133333333332 1.1936466666666665 8.8718 0.9755333333333334 9.399233333333333 1.03704C9.949866666666665 1.10124 10.457733333333334 1.4577866666666666 10.801633333333331 2.109713333333333C11.148866666666665 2.767993333333333 11.143799999999999 3.4356599999999995 11.051833333333335 4.0520933333333335C10.993899999999998 4.44022 10.875366666666666 4.852359999999999 10.773433333333333 5.166686666666666zM4.666666666666666 13.122166666666667L4.666666666666666 6.166686666666667L3.671693333333333 6.166686666666667C3.029613333333333 6.166686666666667 2.5161533333333335 6.615046666666666 2.4545466666666664 7.207833333333333C2.3890599999999997 7.837933333333333 2.333333333333333 8.630433333333333 2.333333333333333 9.501666666666665C2.333333333333333 10.453433333333333 2.399833333333333 11.345266666666667 2.473113333333333 12.038333333333334C2.533993333333333 12.614133333333331 3.0083466666666667 13.053199999999999 3.6107466666666665 13.0807C3.9228066666666668 13.094899999999999 4.278173333333333 13.109333333333334 4.666666666666666 13.122166666666667z"
fill="currentColor"></path>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16"
<svg v-else xmlns="http://www.w3.org/2000/svg" :width="iconSize" :height="iconSize"
viewBox="0 0 16 16">
<path
d="M13.545733333333333 5.166653333333333L10.511766666666666 5.166653333333333C10.658033333333332 4.851813333333333 10.821733333333334 4.440706666666666 10.880833333333332 4.044453333333333C10.923233333333332 3.760413333333333 10.927266666666664 3.412493333333333 10.893133333333333 3.0813333333333333C10.859833333333334 2.7581999999999995 10.784866666666666 2.3969066666666663 10.6352 2.1132133333333334C10.318299999999999 1.5124266666666666 9.882166666666667 1.09052 9.357366666666666 0.9881599999999999C8.799166666666666 0.8792866666666665 8.318566666666666 1.1633 8.030966666666666 1.59852C7.7904333333333335 1.9625133333333333 7.6966 2.26618 7.611066666666667 2.5431266666666668L7.608366666666666 2.5519066666666665C7.526133333333332 2.817973333333333 7.4452333333333325 3.07934 7.237266666666667 3.4476933333333335C6.895133333333334 4.053713333333333 6.615993333333333 4.36802 6.240833333333333 4.69694C6.046326666666666 4.867473333333333 5.84366 4.974753333333333 5.666666666666666 5.03686L5.666666666666666 14.149866666666664C6.190953333333333 14.161133333333334 6.752166666666666 14.168266666666668 7.333333333333333 14.168266666666668C8.896066666666666 14.168266666666668 10.214966666666665 14.117266666666666 11.084633333333333 14.071433333333333C11.938133333333333 14.026433333333333 12.754100000000001 13.5962 13.1998 12.814466666666664C13.621066666666666 12.075666666666665 14.160633333333333 10.9572 14.485833333333334 9.619766666666667C14.7904 8.367233333333333 14.9174 7.348799999999999 14.968999999999998 6.656493333333334C15.032466666666666 5.8043733333333325 14.340166666666665 5.166653333333333 13.545733333333333 5.166653333333333zM4.666666666666666 14.122666666666667L4.666666666666666 5.166653333333333L3.5348733333333335 5.166653333333333C2.506193333333333 5.166653333333333 1.591813333333333 5.90056 1.4747533333333334 6.9655C1.4003066666666666 7.642799999999999 1.3333333333333333 8.523499999999999 1.3333333333333333 9.5016C1.3333333333333333 10.559333333333333 1.4116666666666666 11.540700000000001 1.4928399999999997 12.274533333333332C1.6048399999999998 13.287066666666664 2.43944 14.026599999999998 3.4350066666666668 14.073566666666666C3.78952 14.0903 4.205413333333333 14.107633333333332 4.666666666666666 14.122666666666667z"
fill="currentColor"></path>
</svg>
</button>
<span>{{ alterAmount }}</span>
</span>
<span :style="{fontSize: `${fontSize}px`}">{{ alterAmount > 0 ? alterAmount : ' ' }}</span>
</label>
</template>
@ -93,20 +113,41 @@ label {
cursor: pointer;
user-select: none;
}
label.disable {
cursor: not-allowed;
}
.like {
display: flex;
flex-direction: row;
align-items: center;
align-items: normal;
justify-content: center;
overflow: hidden;
gap: 3px;
color: gray;
}
.disable.like {
cursor: not-allowed;
}
.like.vertical {
align-items: center;
flex-direction: column;
gap: 0;
}
.like button {
color: gray;
padding: 4px 0 0;
}
.like.vertical button {
width: 50px;
}
span {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,13 @@
<script setup>
</script>
<template>
<svg id="icon" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16">
<path d="M9.082233333333333 3.665566666666667C9.082233333333333 4.2632666666666665 8.597733333333332 4.7478066666666665 8 4.7478066666666665C7.4023 4.7478066666666665 6.917766666666666 4.2632666666666665 6.917766666666666 3.665566666666667C6.917766666666666 3.0678666666666663 7.4023 2.583333333333333 8 2.583333333333333C8.597733333333332 2.583333333333333 9.082233333333333 3.0678666666666663 9.082233333333333 3.665566666666667zM9.0823 12.332333333333333C9.0823 12.930066666666665 8.597733333333332 13.414633333333331 8 13.414633333333331C7.402233333333333 13.414633333333331 6.917666666666666 12.930066666666665 6.917666666666666 12.332333333333333C6.917666666666666 11.734566666666666 7.402233333333333 11.25 8 11.25C8.597733333333332 11.25 9.0823 11.734566666666666 9.0823 12.332333333333333zM8 9.083233333333332C8.598299999999998 9.083233333333332 9.0833 8.598233333333333 9.0833 7.9999666666666664C9.0833 7.4016666666666655 8.598299999999998 6.916666666666666 8 6.916666666666666C7.4017 6.916666666666666 6.9167 7.4016666666666655 6.9167 7.9999666666666664C6.9167 8.598233333333333 7.4017 9.083233333333332 8 9.083233333333332z" fill="currentColor"></path>
</svg>
</template>
<style scoped>
</style>

View File

@ -31,10 +31,19 @@
<router-link v-if="!store.getters.hasUserInfo" to="/login">
<button class="login-btn">登录/注册</button>
</router-link>
<router-link v-else to="/account/setting"><div class="user-info">
<router-link v-else to="/account/setting">
<!-- <el-dropdown>-->
<div class="user-info">
<img :src="store.getters.profileImage" alt="User Avatar" class="avatar" />
<span v-if="windowWidth > 550" class="username">{{ store.state.userInfo.username }}</span>
</div></router-link>
</div>
<!-- <template #dropdown>-->
<!-- <el-dropdown-menu>-->
<!-- <el-dropdown-item command="a">Action 1</el-dropdown-item>-->
<!-- </el-dropdown-menu>-->
<!-- </template>-->
<!-- </el-dropdown>-->
</router-link>
</div>
</nav>
</template>
@ -91,6 +100,14 @@ const toggleTheme = () => {
</script>
<style scoped>
.example-showcase .el-dropdown-link {
cursor: pointer;
color: var(--el-color-primary);
display: flex;
align-items: center;
}
.theme-dark {
background-color: rgba(40, 40, 40);
color: #0ff;
@ -98,7 +115,7 @@ const toggleTheme = () => {
.theme-dark .navbar {
background-color: rgba(40, 40, 40);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.theme-dark .nav-left .logo {
@ -141,7 +158,7 @@ const toggleTheme = () => {
.theme-light .navbar {
background-color: #fff;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
}
.theme-light .nav-left .logo {
@ -270,6 +287,14 @@ const toggleTheme = () => {
border-radius: 50%;
}
.user-info .username {
color: white;
}
.theme-light .user-info .username {
color: black;
}
/* 主题切换按钮 */
.theme-toggle-btn {
margin-left: 1rem;

View File

@ -0,0 +1,37 @@
<script setup>
import {computed} from "vue";
import {userProfile} from "../utils/imageResource.js";
const props = defineProps({
id: {
type: String,
required: true,
default: 'default.jpg'
},
size: {
type: Number,
default: 48
}
});
const profileUrl = computed(() => {
return props.id.includes('http') ? props.id : userProfile(props.id)
})
</script>
<template>
<div class="avatar" :style="{width: size+'px', height: size+'px'}">
<img :style="{width: size+'px', height: size+'px'}"
:src="profileUrl"
alt="Avatar"
/>
</div>
</template>
<style scoped>
.avatar, .avatar img {
border-radius: 50%;
overflow: hidden;
}
</style>

View File

@ -2,6 +2,8 @@ import { createApp } from 'vue'
import App from './App.vue'
import store from './store'
import router from './router'
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import $ from 'jquery';
import './style.css'
@ -13,4 +15,5 @@ window.jQuery = $;
app.use(router);
app.use(store);
app.use(ElementPlus);
app.mount('#app')

View File

@ -4,9 +4,10 @@ import MarkdownViewer from '../components/mdRenderer.vue';
import api from "../utils/axios.js";
import swal from "../utils/sweetalert.js";
import PagingController from "../components/PagingController.vue";
import store from "../store/index.js";
const messages = ref([]);
const messages = ref(store.state.sessionStore.webLog ||[]);
const pageLoading = ref(false);
const amount = ref(1);
@ -22,6 +23,7 @@ async function refreshLog(page, size) {
}
amount.value = Math.ceil(res.amount / size);
messages.value = res.data;
store.state.sessionStore.webLog = messages.value;
return 0;
}

View File

@ -5,6 +5,7 @@ import {getRandomIMG} from "../utils/randomIMG.js";
import PagingController from "../components/PagingController.vue";
import api from "../utils/axios.js";
import store from "../store/index.js";
import swal from "../utils/sweetalert.js";
const tags = ref([]);
@ -35,16 +36,25 @@ const refreshBlogs = async (page, size, sort) => {
sort = sort || 'post_date';
const tempPage = currentPage.value;
currentPage.value = page;
const blogsResponse = await api.get(`/blogs?page=${page}&size=${size}&sort=${sort}`);
if (!blogsResponse.amount) {
currentPage.value = tempPage;
return;
try {
const blogsResponse = await api.get(`/blogs?page=${page}&size=${size}&sort=${sort}`);
if (!blogsResponse.amount) {
currentPage.value = tempPage;
swal.tip('error', '加载失败...');
loadingText.value = '加载失败, 请刷新重试'
return;
}
amount.value = Math.ceil(blogsResponse.amount / size);
blogs.value = blogsResponse.data;
currentPage.value = page;
store.commit('setSessionValue', {key: 'blog', value: {blogs: blogs, blogAmount: amount, blogCurrentPage: currentPage}});
console.log(store.state.sessionStore.blog)
} catch {
swal.tip('error', '加载失败...');
loadingText.value = '加载失败, 请刷新重试'
}
amount.value = Math.ceil(blogsResponse.amount / size);
blogs.value = blogsResponse.blogs;
currentPage.value = page;
store.commit('setSessionValue', {key: 'blog', value: {blogs: blogs, blogAmount: amount, blogCurrentPage: currentPage}});
console.log(store.state.sessionStore.blog)
}
onMounted(() => {
@ -69,7 +79,7 @@ onMounted(() => {
<div class="blogs" v-if="amount">
<BlogBox v-for="blog in blogs" :key="blog.title" :blog="blog"/>
</div>
<span v-else style="position: absolute">正在加载...</span>
<span v-else style="position: absolute">{{ loadingText }}</span>
</Transition>
<Transition name="fade">
<PagingController v-if="amount" :current-page="currentPage" :amount="amount"

View File

@ -19,6 +19,14 @@ const demos = ref([
tags: ['课内'],
image: 'https://' + getDomain() + '/data/demos/pod/icon.png'
},
{
id: 'gungame3d',
name: '打枪',
description: 'wasd控制左键开枪r换弹',
date: '2025-3-13',
author: ["Louis Zhou"],
tags: ['游戏'],
},
]);

View File

@ -1,560 +0,0 @@
<template>
<div class="container">
<div class="editor-container">
<Toolbar
class="tool-bar"
:editor="editorRef"
:defaultConfig="toolbarConfig"
:mode="mode"
/>
<Editor
:defaultConfig="editorConfig"
:mode="mode"
v-model="valueHtml"
@onCreated="handleCreated"
@onChange="handleChange"
@onDestroyed="handleDestroyed"
@onFocus="handleFocus"
@onBlur="handleBlur"
@customAlert="customAlert"
@customPaste="customPaste"
/>
</div>
<button class="submit-btn" @click="submitBlog">提交</button>
</div>
</template>
<script setup>
import '@wangeditor/editor/dist/css/style.css';
import {ref, shallowRef, onMounted, onBeforeUnmount} from 'vue';
import {Editor, Toolbar} from '@wangeditor/editor-for-vue';
import api from "../utils/axios.js";
import swal from "../utils/sweetalert.js";
import Swal from "sweetalert2";
import axios from "axios";
// shallowRef
const editorRef = shallowRef();
// HTML
const valueHtml = ref('<p>hello</p>');
const imagesCache = ref([]);
//
const toolbarConfig = {};
const editorConfig = {
placeholder: '请输入内容...',
MENU_CONF: {},
};
//
const mode = 'default';
toolbarConfig.excludeKeys = ["insertImage", "group-video", "fullScreen", "insertTable"];
toolbarConfig.modalAppendToBody = true;
editorConfig.MENU_CONF.uploadImage = {
fieldName: "image", //
async customUpload(file, insertFn) {
const index = imagesCache.value.length;
const imageSize = await getImageSize(file);
const objectURL = URL.createObjectURL(file); // blob URL
imagesCache.value.push({
file,
url: objectURL, // blob URL
originalWidth: imageSize.width,
originalHeight: imageSize.height,
});
insertFn(objectURL, `image-${index}`, objectURL); //
}
};
// 🎯
const getImageSize = (file) => {
return new Promise((resolve) => {
const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => {
resolve({width: img.width, height: img.height});
};
});
};
// Ajax
onMounted(() => {
// setTimeout(() => {
// valueHtml.value = '<p> Ajax </p>';
// // valueHtml.value = valueHtml.value.replaceAll(
// // '<holder>',
// // ''
// // );
// }, 1500);
});
//
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor == null) return;
editor.destroy();
});
async function titleInputWindow() {
try {
while (1) {
const result = await Swal.fire({
title: '请输入标题',
input: 'text',
inputLabel: '标题',
inputPlaceholder: '请输入您的标题...',
showCancelButton: true,
cancelButtonText: '取消',
confirmButtonText: '确定',
inputValidator: (value) => {
if (!value) {
return '标题不能为空!'
}
}
})
//
if (!result.isConfirmed) {
return -1;
}
const title = result.value;
if (title) {
const result = await swal.window('info', `确定吗?`, `用"${title}"作为标题`, '确定', '重输');
if (result.isConfirmed) {
return title;
}
}
}
} catch (error) {
console.error('输入弹窗出错:', error)
}
}
// 🚀
const submitBlog = async () => {
if (!editorRef.value) return;
const title = await titleInputWindow();
if (title === -1) {
return;
}
const response = await swal.window('info', '允许评论吗?', '其他用户可以在你的博客下留言', '允许', '不允许');
let allowComments = response.isConfirmed;
let content = editorRef.value.getHtml(); // HTML
let images = [...imagesCache.value]; //
// `<img>` blob URL
const imgTags = content.match(/<img[^>]+>/g) || [];
const usedUrls = new Set(); // 使 blob URL
imgTags.forEach((imgTag) => {
const srcMatch = imgTag.match(/src=["']([^"']+)["']/);
if (srcMatch) {
usedUrls.add(srcMatch[1]); // 使 blob URL Set
}
});
// imagesCache 使
images = images.filter((img) => {
const isUsed = usedUrls.has(img.url);
if (!isUsed) {
URL.revokeObjectURL(img.url); // 使 blob URL
}
return isUsed; // 使
});
// imagesCache
imagesCache.value = images;
// blob URL
const urlToIndexMap = new Map();
let uniqueIndex = 0;
// blob URL
imgTags.forEach((imgTag) => {
const srcMatch = imgTag.match(/src=["']([^"']+)["']/);
if (srcMatch) {
const src = srcMatch[1]; // blob URL
if (!urlToIndexMap.has(src)) {
urlToIndexMap.set(src, uniqueIndex);
uniqueIndex++;
}
}
});
// `<img>`
imgTags.forEach((imgTag) => {
const srcMatch = imgTag.match(/src=["']([^"']+)["']/);
if (srcMatch) {
const src = srcMatch[1];
const index = urlToIndexMap.get(src); // blob URL
const styleMatch = imgTag.match(/style=["']([^"']+)["']/);
let width = "", height = "";
// images url
const imageData = images.find((img) => img.url === src); // 使 url
const originalWidth = imageData?.originalWidth || 0;
const originalHeight = imageData?.originalHeight || 0;
if (styleMatch && styleMatch[1]) {
const styleStr = styleMatch[1];
const widthMatch = styleStr.match(/width:\s*([\d.]+)px/);
const heightMatch = styleStr.match(/height:\s*([\d.]+)px/);
const percentWidthMatch = styleStr.match(/width:\s*([\d.]+)%/);
if (widthMatch) {
width = widthMatch[1];
} else if (percentWidthMatch && originalWidth) {
width = ((parseFloat(percentWidthMatch[1]) / 100) * originalWidth).toFixed(2);
}
if (heightMatch) {
height = heightMatch[1];
} else if (width && originalWidth && originalHeight) {
height = ((width / originalWidth) * originalHeight).toFixed(2);
}
} else {
width = originalWidth;
height = originalHeight;
}
// 使
content = content.replace(imgTag, `<preholder image ${index} width=${width} height=${height}>`);
}
});
// 2
const formData = new FormData();
formData.append("title", title);
formData.append("content", content);
formData.append("allow_comments", allowComments);
formData.append("draft", false);
console.log(images);
images.forEach((imgData) => {
formData.append(`images`, imgData.file); //
});
console.log(Object.fromEntries(formData.entries()));
// 3
api.post('/blogs', formData, {
headers: {
"Content-Type": "multipart/form-data",
},
}).then(response => {
if (response.code === 0) {
swal.window('success', `提交成功, 博客id${response.blogId || '未找到blogId字段'}`,'', 'ok', '好的');
return;
}
swal.window('error', '提交失败', `code字段为${response.code}; 错误信息: ${response.message}`, 'ok', '好的');
}).catch((e) => {
swal.tip('error', `错误${e.message}`)
});
};
//
const handleCreated = (editor) => {
// console.log('created', editor);
editorRef.value = editor; // editor
};
const handleChange = (editor) => {
// console.log('change:', editor.getHtml());
};
const handleDestroyed = (editor) => {
// console.log('destroyed', editor);
};
const handleFocus = (editor) => {
// console.log('focus', editor);
};
const handleBlur = (editor) => {
// console.log('blur', editor);
};
const customAlert = (info, type) => {
// alert(`${type} - ${info}`);
};
const customPaste = (editor, event, callback) => {
// console.log('ClipboardEvent ', event);
// editor.insertText('xxx');
// callback(false); //
};
//
const insertText = () => {
const editor = editorRef.value;
if (editor == null) return;
editor.insertText('hello world');
};
const printHtml = () => {
const editor = editorRef.value;
if (editor == null) return;
console.log(editor.getHtml());
};
const disable = () => {
const editor = editorRef.value;
if (editor == null) return;
editor.disable();
};
</script>
<style scoped>
.container {
height: calc(100vh - 65px);
max-width: 1300px;
width: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.submit-btn {
position: absolute;
right: 0;
bottom: 0;
width: 80px;
height: 40px;
border: #ffb74d solid 3px;
border-radius: 5px;
margin: 5px;
opacity: 0.3;
transition: all 0.2s ease;
}
.submit-btn:hover {
opacity: 1;
background: #ffb74d;
}
.editor-container {
width: 99%;
height: 100vh;
max-height: 100%;
margin-top: 10px;
display: flex;
flex-direction: column;
border: 1px solid #5d5d5d;
overflow: hidden;
}
.theme-light .editor-container {
border: 1px solid #c4c4c4;
}
</style>
<style>
div[data-slate-editor] {
max-height: 0;
}
.w-e-text-container {
background: #2a2a2a;
}
.w-e-toolbar {
background: #1c1c1c;
border-bottom: 1px solid #595959;
}
.w-e-toolbar button {
color: #e5e5e5;
background-color: #262626;
}
.w-e-bar-item button:hover {
color: white;
background-color: #494949;
}
.w-e-bar-item .disabled:hover {
background-color: #494949;
}
.w-e-bar-item-group .w-e-bar-item-menus-container {
background-color: #262626;
border: #464646 solid 1px;
}
.w-e-toolbar svg {
fill: #e5e5e5;
}
.w-e-drop-panel {
background: #262626;
}
.w-e-panel-content-table {
background-color: #262626;
color: white;
}
.w-e-panel-content-table td {
background-color: #262626;
}
.w-e-panel-content-table td.active {
background-color: #494949;
}
#w-e-textarea-1 {
background: #000000;
color: white;
}
.w-e-bar-divider {
background: #595959;
}
.w-e-select-list {
background: #262626;
}
.w-e-select-list ul {
background: #262626;
color: white;
}
.w-e-select-list ul .selected {
background: #494949;
}
.w-e-select-list ul li:hover {
background: #595959;
}
.w-e-drop-panel, .w-e-select-list {
border: #464646 solid 1px;
}
.w-e-panel-content-color li {
border: #464646 solid 1px;
}
.w-e-panel-content-color li .color-block {
border: #464646 solid 1px;
}
.w-e-hover-bar {
background: black;
}
/* 亮色模式 */
.theme-light .w-e-text-container {
background: #ffffff;
}
.theme-light .w-e-toolbar {
background: #f5f5f5;
border-bottom: 1px solid #d4d4d4;
}
.theme-light .w-e-toolbar button {
color: #333333;
background-color: #ebebeb;
}
.theme-light .w-e-bar-item .disabled svg {
fill: #b6b6b6;
}
.theme-light .w-e-bar-item .disabled {
color: #b6b6b6;
}
.theme-light .w-e-bar-item button:hover {
color: black;
background-color: #dcdcdc;
}
.theme-light .w-e-bar-item .disabled:hover {
color: #b6b6b6;
background-color: #dcdcdc;
}
.theme-light .w-e-bar-item-group .w-e-bar-item-menus-container {
background-color: #ebebeb;
border: #d4d4d4 solid 1px;
}
.theme-light .w-e-toolbar svg {
fill: #333333;
}
.theme-light .w-e-drop-panel {
background: #ebebeb;
}
.theme-light .w-e-panel-content-table {
background-color: #ffffff;
color: black;
}
.theme-light .w-e-panel-content-table td {
background-color: #ffffff;
}
.theme-light .w-e-panel-content-table td.active {
background-color: #dcdcdc;
}
.theme-light #w-e-textarea-1 {
background: #ffffff;
color: black;
}
.theme-light .w-e-bar-divider {
background: #d4d4d4;
}
.theme-light .w-e-select-list {
background: #ebebeb;
}
.theme-light .w-e-select-list ul {
background: #ebebeb;
color: black;
}
.theme-light .w-e-select-list ul .selected {
background: #dcdcdc;
}
.theme-light .w-e-select-list ul li:hover {
background: #d4d4d4;
}
.theme-light .w-e-drop-panel, .theme-light .w-e-select-list {
border: #d4d4d4 solid 1px;
}
.theme-light .w-e-panel-content-color li {
border: #d4d4d4 solid 1px;
}
.theme-light .w-e-panel-content-color li .color-block {
border: #d4d4d4 solid 1px;
}
.theme-light .w-e-hover-bar {
background: white;
}
</style>

View File

@ -1,26 +1,11 @@
<script setup>
import {onMounted, ref} from "vue";
import {blogImage} from "../utils/imageResource.js";
const blogDisplay = ref(null);
onMounted(() => {
})
</script>
<template>
<div class="container">
<div v-html="blogDisplay"></div>
</div>
</template>
<style scoped>
.container {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
</style>

View File

@ -25,25 +25,25 @@ onMounted(() => {
<div class="sidebar">
<ul v-if="!isMedia || showMobileMenu">
<li>
<router-link to="/account/setting">账号设置</router-link>
<router-link to="/account/setting" :class="{'current':$route.path.includes('setting')}">账号设置</router-link>
</li>
<li>
<router-link to="/account/self-page">个人主页</router-link>
<router-link to="/account/self-page" :class="{'current':$route.path.includes('self-page')}">个人主页</router-link>
</li>
<li>
<router-link to="/account/works-manage">稿件管理</router-link>
<router-link to="/account/works-manage" :class="{'current':$route.path.includes('works-manage')}">稿件管理</router-link>
</li>
<li>
<router-link to="/account/draft">草稿箱</router-link>
<router-link to="/account/draft" :class="{'current':$route.path.includes('draft')}">草稿箱</router-link>
</li>
<li v-if="store.getters.isAdmin">
<router-link to="/account/upload-log">管理: 上传日志</router-link>
<router-link to="/account/upload-log" :class="{'current':$route.path.includes('upload-log')}">管理: 上传日志</router-link>
</li>
<li v-if="store.getters.isAdmin">
<router-link to="/account/user-management">管理: 管理用户</router-link>
<router-link to="/account/user-management" :class="{'current':$route.path.includes('user-management')}">管理: 管理用户</router-link>
</li>
<li v-if="isMobile">
<router-link to="/account">用户信息</router-link>
<router-link to="/account" :class="{'current':($route.path === '/account')}">用户信息</router-link>
</li>
</ul>
<button v-if="isMedia" @click="showMobileMenu = !showMobileMenu">
@ -91,6 +91,10 @@ onMounted(() => {
margin: 20px 0;
}
.sidebar li .current {
background: rgba(129,129,129,0.1);
}
.sidebar a {
color: #fff;
text-decoration: none;

View File

@ -10,7 +10,7 @@ import Swal from "sweetalert2";
const currentPage = ref(1);
const amount = ref(1);
const userList = ref([]);
const userList = ref(store.state.sessionStore.userList ||[]);
const pageLoading = ref(false);
const refreshList = async (page, pageSize) => {
@ -23,6 +23,7 @@ const refreshList = async (page, pageSize) => {
}
amount.value = Math.ceil(response.amount / pageSize);
userList.value = response.data.map((user) => {return {...user, ...{showEmail: false}}});
store.state.sessionStore.userList = userList.value;
pageLoading.value = false;
return 0;
} catch {

View File

@ -1,15 +1,46 @@
<script setup>
import { ref } from 'vue'
import {onMounted, ref} from 'vue'
import AccountWorkPiece from "../../components/AccountWorkPiece.vue";
import router from "../../router/index.js";
import {getInfoWithPages} from "../../utils/getInfoWithPages.js";
import swal from "../../utils/sweetalert.js";
import store from "../../store/index.js";
import PagingController from "../../components/PagingController.vue";
// 稿
const drafts = ref([
])
const drafts = ref(store.state.sessionStore.account?.Drafts || null)
const amount = ref(store.state.sessionStore.account?.DraftsAmount || 1);
const currentPage = ref(store.state.sessionStore.account?.DraftsCurrentPage || 1);
function createNewDraft() {
router.push('/editor')
store.state.editStore.currentBlogId = null;
router.push('/editor');
}
async function refreshDraft(page, size, sort) {
page = page || currentPage.value;
size = size || 15;
sort = sort || 'post';
const result = await getInfoWithPages('/self/blogs/draft', page, size, {sort: sort});
if (result.code === 0) {
drafts.value = result.blogs;
amount.value = Math.ceil(amount.value / size);
store.state.sessionStore.account.Drafts = drafts.value;
store.state.sessionStore.account.DraftsAmount = amount.value;
store.state.sessionStore.account.DraftsCurrentPage = page;
} else {
swal.tip('error', '加载失败...');
drafts.value = [];
}
}
async function goPage(page) {
refreshDraft(page, 15);
}
onMounted(async () => {
refreshDraft(1, 15);
})
</script>
<template>
@ -17,6 +48,7 @@ function createNewDraft() {
<!-- 顶部按钮居中 -->
<div class="top-bar">
<button class="create-btn" @click="createNewDraft">新建博客</button>
<div v-if="drafts === null">正在加载...</div>
</div>
<!-- 下面循环渲染草稿展示条 -->
<div class="draft-list">
@ -25,11 +57,13 @@ function createNewDraft() {
:key="index"
:cover="item.cover"
:title="item.title"
:createdTime="item.createdTime"
:lastModifiedTime="item.lastModifiedTime"
:createdTime="item.post_date"
:lastModifiedTime="item.edit_date"
:isDraft="true"
/>
:id="item.id"
@refresh="refreshDraft"/>
</div>
<PagingController v-if="amount > 1" :current-page="currentPage" :amount="amount" :go-page-func="goPage"/>
</div>
</template>
@ -42,6 +76,7 @@ function createNewDraft() {
flex-direction: column;
align-items: center;
color: #f5f6f7;
margin-bottom: 40px;
animation: fadeIn 0.3s ease-in-out;
}
@ -52,7 +87,9 @@ function createNewDraft() {
.top-bar {
display: flex;
flex-direction: column;
justify-content: center;
gap: 20px;
margin: 20px 0;
}
@ -64,6 +101,7 @@ function createNewDraft() {
border-radius: 4px;
cursor: pointer;
}
.create-btn:hover {
background-color: #2f5687;
}
@ -72,6 +110,7 @@ function createNewDraft() {
background-color: #ffc107;
color: #333333;
}
.theme-light .create-btn:hover {
background-color: #e0a806;
}
@ -82,6 +121,7 @@ function createNewDraft() {
gap: 10px;
width: 80%;
max-width: 800px;
margin-bottom: 40px;
}
@keyframes fadeIn {

View File

@ -73,7 +73,9 @@ function cancelEditUsername() {
function logout() {
AuthService.logout();
router.replace('/login')
store.commit('cleanEditStore');
store.state.sessionStore.account = {};
router.replace('/login');
}
//

View File

@ -1,56 +1,93 @@
<script setup>
import { ref } from 'vue'
import {onMounted, ref} from 'vue'
import AccountWorkPiece from "../../components/AccountWorkPiece.vue";
import router from "../../router/index.js";
import {getInfoWithPages} from "../../utils/getInfoWithPages.js";
import swal from "../../utils/sweetalert.js";
import store from "../../store/index.js";
import PagingController from "../../components/PagingController.vue";
const works = ref([
// 稿
const works = ref(store.state.sessionStore.account?.works ||null)
const amount = ref(store.state.sessionStore.account?.worksAmount || 1);
const currentPage = ref(store.state.sessionStore.account?.worksCurrentPage || 1);
])
function createNewBlog() {
router.push('/editor')
function createNewwork() {
store.state.editStore.currentBlogId = null;
router.push('/editor');
}
async function refreshwork(page, size, sort) {
sort = sort || 'post';
const result = await getInfoWithPages('/self/blogs/posted', page, size, {sort: sort});
if (result.code === 0) {
works.value = result.blogs;
amount.value = Math.ceil(amount.value / size);
store.state.sessionStore.account.works = works.value;
store.state.sessionStore.account.worksAmount = amount.value;
store.state.sessionStore.account.worksCurrentPage = page;
} else {
swal.tip('error', '加载失败...');
works.value = [];
}
}
async function goPage(page) {
refreshwork(page, 15);
}
onMounted(async () => {
refreshwork(1, 15);
})
</script>
<template>
<div class="container">
<!-- 顶部按钮居中 -->
<div class="top-bar">
<button class="create-btn" @click="createNewBlog">新建博客</button>
<button class="create-btn" @click="createNewwork">新建博客</button>
<div v-if="works === null">正在加载...</div>
</div>
<div class="works-list">
<!-- 下面循环渲染草稿展示条 -->
<div class="work-list">
<AccountWorkPiece
v-for="(item, index) in works"
:key="index"
:cover="item.cover"
:title="item.title"
:createdTime="item.createdTime"
:lastModifiedTime="item.lastModifiedTime"
:isDraft="false"
/>
:createdTime="item.post_date"
:lastModifiedTime="item.edit_date"
:iswork="true"
:id="item.id"
@refresh="refreshwork"/>
</div>
<PagingController v-if="amount > 1" :current-page="currentPage" :amount="amount" :go-page-func="goPage"/>
</div>
</template>
<style scoped>
/* 容器基础样式 */
.container {
width: 100%;
overflow-y: auto; /* 或 scroll */
height: auto;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
color: #f5f6f7;
margin-bottom: 40px;
animation: fadeIn 0.3s ease-in-out;
}
:deep(.theme-light) .container {
.theme-light .container {
background-color: #ffffff;
color: #333333;
}
.top-bar {
display: flex;
flex-direction: column;
justify-content: center;
gap: 20px;
margin: 20px 0;
}
@ -74,12 +111,13 @@ function createNewBlog() {
background-color: #e0a806;
}
.works-list {
.work-list {
display: flex;
flex-direction: column;
gap: 10px;
width: 80%;
max-width: 800px;
margin-bottom: 40px;
}
@keyframes fadeIn {

View File

@ -4,31 +4,91 @@ import {useRoute} from 'vue-router'
import api from '../../utils/axios.js'
import {blogImage, userProfile} from '../../utils/imageResource'
import router from "../../router/index.js";
import swal from "../../utils/sweetalert.js";
import Like_button from "../../components/Like_button.vue";
import store from "../../store/index.js";
import Blog_rootComment from "../../components/Blog_rootComment.vue";
import {getInfoWithPages} from "../../utils/getInfoWithPages.js";
import Blog_commentDisplay from "../../components/Blog_commentDisplay.vue";
import Profile_display from "../../components/Profile_display.vue";
import {getCurrentISODateTime} from "../../utils/formatTime.js";
//
const route = useRoute()
const blog = ref(null)
const blog = ref(
{
complete: false
}
// {
// complete: true,
// title: 'asd',
// content: `${'asdasd'.repeat(999)}`,
// allowComment: true,
// post_date: 'asd',
// }
)
const posterInfo = ref(null)
const interactInfo = ref({
complete: false
})
const id = route.params.id
const isLoadingFailed = ref(false);
const windowWidth = ref(0);
const scrollRef = ref(null);
const commentInput = ref('');
const commentInputFocus = ref(false);
const commentButtonFocus = ref(false);
const commentSubmitLoading = ref(false);
const checkWindowSize = () => {
windowWidth.value = window.innerWidth;
};
//
onMounted(async () => {
// setTimeout(() => {
// interactInfo.value.likes = 114;
// interactInfo.value.liked = true;
// interactInfo.value.complete = true;
// }, 100);
checkWindowSize();
window.addEventListener('resize', checkWindowSize);
//
const blogResponse = await api.get(`/blogs/${id}`)
if (blogResponse.code === 1) {
await router.push('/404')
return;
}
if (blogResponse.code === 0) {
blog.value = blogResponse.blog
//
const posterResponse = await api.get(`/userinfo?UID=${blog.value.poster}`)
if (posterResponse.code === 0) {
posterInfo.value = posterResponse.info
} else {
console.error('Failed to fetch poster info:', posterResponse)
try {
const blogResponse = await api.get(`/blogs/${id}`);
console.log(blogResponse)
if (blogResponse.code === 1) {
await router.push('/404')
return;
}
if (blogResponse.code === 0) {
blog.value = blogResponse.data;
document.title = blog.value.title + ' CYBER 博客';
blog.value.complete = true;
console.log(blog.value)
setTimeout(() => {
interactInfo.value.likes = blog.value.likes;
interactInfo.value.liked = blog.value.liked;
interactInfo.value.complete = true;
}, 300);
//
const posterResponse = await api.get(`/userinfo?UID=${blog.value.poster}`)
if (posterResponse.code === 0) {
posterInfo.value = posterResponse.data
}
}
} catch {
swal.tip('error', '加载失败...', '请刷新重试');
isLoadingFailed.value = true;
}
})
// <holder> <img>
@ -41,40 +101,172 @@ const processedContent = computed(() => {
return `<img src="${url}" width="${width}" height="${height}" alt="Blog Image" />`
})
})
const clickLikeBtn = async (isLiked) => {
try {
if (isLiked) {
const response = await api.post(`/blogs/${blog.value.id}/like`);
if (response.code !== 0) {
swal.tip('error', '点赞失败');
}
} else {
const response = await api.delete(`/blogs/${blog.value.id}/like`);
if (response.code !== 0) {
swal.tip('error', '取消点赞失败');
}
}
} catch {
swal.tip('error', '网络错误');
}
}
const commentDisplayRef = ref(null);
const submitComment = async () => {
const commentBody = commentInput.value.trim();
if (!commentBody) {
return;
}
commentSubmitLoading.value = true;
try {
const response = await api.post(`/blogs/${id}/comments`, (() => {
const form = new FormData();
form.append('content', commentBody);
return form;
})()
)
;
if (response.code === 0) {
commentDisplayRef.value.addCommentToFront({
"belong_blog": id,
"content": commentBody,
"date": getCurrentISODateTime(),
"father": 0,
"id": response.commentId,
"likes": 0,
"poster_name": store.state.userInfo.username,
"profile": store.state.userInfo.profile,
"uid": store.state.userInfo.uid
})
} else {
swal.tip('error', '发送失败, 未知错误');
}
} catch {
swal.tip('error', '发送失败, 网络错误');
}
commentSubmitLoading.value = false;
}
</script>
<template>
<div class="blog-container">
<!-- 使用 Transition 包裹博客内容 -->
<Transition name="fade">
<div v-if="blog" class="blog-wrapper">
<h1>{{ blog.title }}</h1>
<div class="blog-meta">
<div class="avatar">
<img
v-if="posterInfo"
:src="userProfile(posterInfo.profile)"
alt="Poster Avatar"
/>
<div class="container" ref="scrollRef">
<div class="scroll-container">
<div class="blog-container">
<!-- 使用 Transition 包裹博客内容 -->
<Transition name="fade">
<div v-show="blog.complete" class="blog-wrapper">
<h1>{{ blog.title }}</h1>
<div class="blog-meta">
<div class="avatar">
<Profile_display v-if="posterInfo" :id="posterInfo.profile"/>
</div>
<div class="info-text">
<span class="username-text">{{ posterInfo?.username || '' }}</span>
<span class="date-text">{{ blog.post_date }}</span>
</div>
</div>
<el-divider/>
<div class="blog-content">
<div style="padding: 0 56px 40px" v-html="processedContent"/>
<el-divider v-if="windowWidth<1050"/>
<Transition name="fade">
<div class="interact-bar" v-if="interactInfo.complete" :class="{outside: windowWidth>1050}">
<Like_button
:disable="!store.getters.hasUserInfo"
:amount="interactInfo.likes"
:active="interactInfo.liked"
:toggle-func="clickLikeBtn"
direction="v"
font-size="16"
icon-size="24"></Like_button>
</div>
</Transition>
<div class="comment-input" v-if="store.getters.hasUserInfo">
<el-container>
<el-aside class="comment-input-profile" width="60px">
<Profile_display v-if="posterInfo" :id="store.getters.profileImage" size="55"/>
</el-aside>
<el-main>
<el-container class="comment-area">
<el-input
autosize
type="textarea"
placeholder="写点什么..."
v-model="commentInput"
@focus="commentInputFocus = true"
@blur="commentInputFocus = false"
/>
<el-button
class="submit-root-commit"
type="primary"
v-if="commentInputFocus || commentButtonFocus || commentSubmitLoading"
:disabled="commentInput.trim() === '' || commentSubmitLoading"
@mouseenter="commentButtonFocus = true"
@mouseleave="commentButtonFocus = false"
@click="submitComment"
>{{ commentSubmitLoading ? "稍等" : "提交" }}
</el-button>
</el-container>
</el-main>
</el-container>
</div>
</div>
<Transition name="fade">
<div class="comments-section">
<Blog_commentDisplay
ref="commentDisplayRef"
v-if="blog.allow_comments === 1"
:scroll-container="scrollRef"
:blog-id="Number(id)"
/>
<p v-else>不允许评论</p>
</div>
</Transition>
</div>
<span>{{ posterInfo?.username || '' }}</span>
<span>{{ blog.post_date }}</span>
</div>
<div class="blog-content" v-html="processedContent"></div>
<div class="comments-section">
<Transition name="fade">
<p v-if="blog.allow_comments === 1">评论区域待实现</p>
<p v-else>不允许评论</p>
</Transition>
</div>
</Transition>
<!-- blog 未加载时显示加载提示 -->
<div v-if="!isLoadingFailed && !blog.complete" class="loading">加载中...</div>
<div v-if="isLoadingFailed" class="loading">加载失败, 请刷新重试</div>
</div>
<!-- blog 未加载时显示加载提示 -->
<div v-else class="loading">加载中...</div>
</Transition>
</div>
</div>
</template>
<style scoped>
.container {
background: #020202;
}
.scroll-container {
width: 100%;
height: 100%;
}
.theme-light .container {
background: #e3e3e3;
}
h1 {
font-size: 2rem;
}
.loading {
height: 0;
text-align: center;
@ -86,7 +278,9 @@ const processedContent = computed(() => {
display: flex;
flex-direction: column;
align-items: flex-start;
height: calc(100vh - 130px);
height: 100%;
}
.blog-container {
@ -94,64 +288,122 @@ const processedContent = computed(() => {
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
width: calc(100% - 60px);
height: auto;
overflow: auto;
max-width: 1200px;
max-width: 800px;
margin: 0 auto;
padding: 20px 30px 50px;
background-color: #333; /* 默认深色背景 */
background-color: #1f1f1f; /* 默认深色背景 */
color: #fff; /* 默认深色文字 */
}
.blog-container.outside {
}
.blog-meta {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
margin-bottom: 20px;
}
.avatar, .avatar img {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
.info-text {
padding-left: 20px;
display: flex;
flex-direction: column;
}
.username-text {
//cursor: pointer;
font-size: 17px;
font-weight: 600;
}
.date-text {
font-size: 13px;
opacity: 0.6;
}
.blog-content {
margin-bottom: 20px;
width: 100%;
word-break: break-word;
}
.comment-input-profile {
display: flex;
//align-items: center;
padding-top: 20px;
justify-content: center;
}
.comment-area {
margin: 10px 0;
display: flex;
flex-direction: column;
align-items: center;
height: auto;
gap: 10px;
}
.submit-root-commit {
width: 83px;
}
.interact-bar {
width: 46px;
height: auto;
padding: 12px 6px;
border-radius: 10px;
}
.interact-bar.outside {
top: 25%;
position: absolute;
background: #333;
margin-left: 850px;
}
.theme-light .interact-bar {
background: #fff;
}
.comments-section {
margin-top: 20px;
width: 100%;
}
/* 浅色模式 */
.theme-light .blog-container {
background-color: #ffffff;
color: #333;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
//box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease, transform 0.5s ease;
.infinite-list-wrapper {
height: 300px;
text-align: center;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(20px); /* 从下方 20px 进入 */
.infinite-list-wrapper .list {
padding: 0;
margin: 0;
list-style: none;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
transform: translateY(0);
.infinite-list-wrapper .list-item {
display: flex;
align-items: center;
justify-content: center;
height: 50px;
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.infinite-list-wrapper .list-item + .list-item {
margin-top: 10px;
}
</style>

View File

@ -0,0 +1,799 @@
<template>
<div class="container">
<el-container>
<el-aside width="auto">
<div class="side-bar" v-if="blogStatus !== null">
<el-button v-if="blogStatus !== 1" type="primary" @click="submitBlog">发布{{ mobileTest() ? '' : '博客' }}</el-button>
<el-button type="success" @click="saveBtn">{{ blogStatus === 1 ? '更新' : '保存' }}{{ mobileTest() ? '' : (blogStatus === 0 ? '草稿' : '博客') }}</el-button>
<el-button v-if="!viewing" type="warning" @click="viewing = true">{{
mobileTest() ? '' : '页面'
}}浏览
</el-button>
<el-button v-else type="warning" @click="viewing = false">返回{{ mobileTest() ? '' : '编辑' }}</el-button>
<el-button type="danger" @click="router.back()">退出{{ mobileTest() ? '' : '编辑' }}</el-button>
</div>
</el-aside>
<el-container v-if="!viewing">
<el-header>
<el-input v-model="titleInput" placeholder="输入标题"/>
</el-header>
<el-main>
<div class="editor-container">
<Toolbar
class="tool-bar"
:editor="editorRef"
:defaultConfig="toolbarConfig"
:mode="mode"
/>
<Editor
:defaultConfig="editorConfig"
:mode="mode"
v-model="valueHtml"
@onCreated="handleCreated"
@onChange="handleChange"
@onDestroyed="handleDestroyed"
@onFocus="handleFocus"
@onBlur="handleBlur"
@customAlert="customAlert"
@customPaste="customPaste"
/>
</div>
</el-main>
</el-container>
<el-container v-else class="viewer-container">
<div class="title">{{ titleInput }}</div>
<div class="blog-meta">
<div class="avatar">
<img
v-if="store.getters.profileImage"
:src="userProfile(store.state.userInfo.profile)"
alt="Poster Avatar"
/>
</div>
<span>{{ store.state.userInfo.username || '' }}</span>
</div>
<div class="viewer" v-html="editorRef.getHtml()"></div>
</el-container>
</el-container>
</div>
</template>
<script setup>
import '@wangeditor/editor/dist/css/style.css';
import {ref, shallowRef, onMounted, onBeforeUnmount} from 'vue';
import {Editor, Toolbar} from '@wangeditor/editor-for-vue';
import api from "../../utils/axios.js";
import swal from "../../utils/sweetalert.js";
import Swal from "sweetalert2";
import axios from "axios";
import mobileTest from "../../utils/mobileTest.js";
import {blogImage, userProfile} from "../../utils/imageResource.js";
import store from "../../store/index.js";
import router from "../../router/index.js";
// shallowRef
const editorRef = shallowRef();
const blogStatus = ref(null);
const titleInput = ref('');
const viewing = ref(false);
// HTML
const valueHtml = ref('');
const imagesCache = ref([]);
//
const toolbarConfig = {};
const editorConfig = {
placeholder: '请输入内容...',
MENU_CONF: {},
};
//
const mode = 'default';
toolbarConfig.excludeKeys = ["insertImage", "group-video", "fullScreen", "insertTable"];
toolbarConfig.modalAppendToBody = true;
editorConfig.MENU_CONF.uploadImage = {
fieldName: "image", //
async customUpload(file, insertFn) {
const index = imagesCache.value.length;
const imageSize = await getImageSize(file);
const objectURL = URL.createObjectURL(file); // blob URL
imagesCache.value.push({
file,
url: objectURL, // blob URL
originalWidth: imageSize.width,
originalHeight: imageSize.height,
});
insertFn(objectURL, `image-${index}`, objectURL); //
}
};
// 🎯
const getImageSize = (file) => {
return new Promise((resolve) => {
const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => {
resolve({width: img.width, height: img.height});
};
});
};
onMounted(() => {
const observer = new MutationObserver(() => {
document.querySelectorAll('[data-menu-key="editImage"]').forEach(btn => {
btn.remove();
});
document.querySelectorAll('[data-menu-key="viewImageLink"]').forEach(btn => {
btn.remove();
});
});
// DOM
observer.observe(document.body, { childList: true, subtree: true });
const currentBlogId = store.state.editStore.currentBlogId;
if (currentBlogId) {
store.commit('startLoading', '正在加载...');
api.get(`/blogs/${currentBlogId}`).then(response => {
if (response.code === 0) {
titleInput.value = response.data.title;
blogStatus.value = response.data.status;
valueHtml.value = response.data.content.replaceAll(/<holder image ([\w.]+\.\w+) width=([\d.]+) height=([\d.]+)>/g, ((match, filename, width, height) => {
const url = blogImage(filename); // filename "1a2b3c4d.png"
return `<img src="${url}" style="width:${width}px; height:${height}px" alt="on-server" />`;
}));
store.commit('stopLoading');
return;
}
swal.tip('error', '加载失败, 自动创建新博客');
store.state.editStore.currentBlogId = null;
blogStatus.value = -1;
store.commit('stopLoading');
}).catch(e => {
swal.tip('error', '加载失败, 自动创建新博客');
store.state.editStore.currentBlogId = null;
blogStatus.value = -1;
store.commit('stopLoading');
})
} else {
blogStatus.value = -1;
}
window.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.key === 's') {
event.preventDefault();
saveBtn();
}});
store.state.editStore.isEditing = true;
window.addEventListener("beforeunload", refreshAlert);
});
const refreshAlert = (event) => {
event.preventDefault();
event.returnValue = ""; //
}
//
onBeforeUnmount(() => {
window.removeEventListener("beforeunload", refreshAlert);
store.state.editStore.isEditing = false;
const editor = editorRef.value;
if (editor == null) return;
editor.destroy();
});
const saveBtn = async () => {
let title = titleInput.value;
if (!title) {
title = await titleInputWindow();
if (title === -1) {
return;
}
titleInput.value = title;
}
store.commit('startLoading', `正在${blogStatus.value === 0 ? '保存' : '更新'}`);
const result = await saveBlog(title);
store.commit('stopLoading');
switch (result) {
case 0:
swal.tip('success', `${blogStatus.value === 0 ? '保存' : '更新'}成功`);
break;
case 1:
swal.tip('error', `${blogStatus.value === 0 ? '保存' : '更新'}失败`);
break;
case 2:
swal.tip('error', `网络错误`)
break;
}
return result;
}
const saveBlog = async (title) => {
let content = editorRef.value.getHtml(); // HTML
let images = [...imagesCache.value]; //
// `<img>` blob URL
const imgTags = content.match(/<img[^>]+>/g) || [];
const usedUrls = new Set(); // 使 blob URL
imgTags.forEach((imgTag) => {
const srcMatch = imgTag.match(/src=["']([^"']+)["']/);
if (srcMatch) {
usedUrls.add(srcMatch[1]); // 使 blob URL Set
}
});
// imagesCache 使
images = images.filter((img) => {
const isUsed = usedUrls.has(img.url);
if (!isUsed) {
URL.revokeObjectURL(img.url); // 使 blob URL
}
return isUsed; // 使
});
// imagesCache
imagesCache.value = images;
// blob URL
const urlToIndexMap = new Map();
let uniqueIndex = 0;
// img
imgTags.forEach((imgTag) => {
const srcMatch = imgTag.match(/src=["']([^"']+)["']/);
const altMatch = imgTag.match(/alt=["']([^"']+)["']/);
if (srcMatch) {
const src = srcMatch[1];
const alt = altMatch ? altMatch[1] : '';
// alt="on-server"
if (alt === "on-server") {
const styleMatch = imgTag.match(/style=["']([^"']+)["']/);
let width = "", height = "";
if (styleMatch && styleMatch[1]) {
const styleStr = styleMatch[1];
const widthMatch = styleStr.match(/width:\s*([\d.]+)px/);
const heightMatch = styleStr.match(/height:\s*([\d.]+)px/);
width = widthMatch ? widthMatch[1] : "219.86"; //
height = heightMatch ? heightMatch[1] : "288.79"; //
} else {
width = "219.86"; //
height = "288.79"; //
}
// src /path/to/1a2b3c4d.jpg
const filenameMatch = src.match(/[^/]+\.(jpg|jpeg|png|gif)/i);
const filename = filenameMatch ? filenameMatch[0] : "1a2b3c4d.jpg";
// holder
content = content.replace(imgTag, `<holder image ${filename} width=${width} height=${height}>`);
}
// blob URL
else if (!urlToIndexMap.has(src)) {
urlToIndexMap.set(src, uniqueIndex);
uniqueIndex++;
}
}
});
// on-server
imgTags.forEach((imgTag) => {
const srcMatch = imgTag.match(/src=["']([^"']+)["']/);
const altMatch = imgTag.match(/alt=["']([^"']+)["']/);
if (srcMatch) {
const src = srcMatch[1];
const alt = altMatch ? altMatch[1] : '';
// alt="on-server"
if (alt === "on-server") return;
const index = urlToIndexMap.get(src);
if (index !== undefined) {
const styleMatch = imgTag.match(/style=["']([^"']+)["']/);
let width = "", height = "";
const imageData = images.find((img) => img.url === src);
const originalWidth = imageData?.originalWidth || 0;
const originalHeight = imageData?.originalHeight || 0;
if (styleMatch && styleMatch[1]) {
const styleStr = styleMatch[1];
const widthMatch = styleStr.match(/width:\s*([\d.]+)px/);
const heightMatch = styleStr.match(/height:\s*([\d.]+)px/);
const percentWidthMatch = styleStr.match(/width:\s*([\d.]+)%/);
if (widthMatch) {
width = widthMatch[1];
} else if (percentWidthMatch && originalWidth) {
width = ((parseFloat(percentWidthMatch[1]) / 100) * originalWidth).toFixed(2);
}
if (heightMatch) {
height = heightMatch[1];
} else if (width && originalWidth && originalHeight) {
height = ((width / originalWidth) * originalHeight).toFixed(2);
}
} else {
width = originalWidth;
height = originalHeight;
}
content = content.replace(imgTag, `<preholder image ${index} width=${width} height=${height}>`);
}
}
});
//
const formData = new FormData();
formData.append("title", title);
formData.append("content", content);
console.log(images);
images.forEach((imgData) => {
formData.append(`images`, imgData.file);
});
console.log(Object.fromEntries(formData.entries()));
//
try {
let response;
let blogId = store.state.editStore.currentBlogId;
if (blogId) {
response = await api.put(`/blogs/${blogId}`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
} else {
response = await api.post('/blogs', formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
if (response.code === 0) {
store.state.editStore.currentBlogId = response.blogId;
}
}
if (response.code === 0) {
return 0;
}
return 1;
} catch {
return 2;
}
}
async function titleInputWindow() {
try {
while (1) {
const result = await Swal.fire({
title: '请输入标题',
input: 'text',
inputLabel: '标题',
inputPlaceholder: '请输入您的标题...',
showCancelButton: true,
cancelButtonText: '取消',
confirmButtonText: '确定',
inputValidator: (value) => {
if (!value) {
return '标题不能为空!'
}
}
})
//
if (!result.isConfirmed) {
return -1;
}
const title = result.value;
if (title) {
const result = await swal.window('info', `确定吗?`, `用"${title}"作为标题`, '确定', '重输');
if (result.isConfirmed) {
return title;
}
}
}
} catch (error) {
console.error('输入弹窗出错:', error)
}
}
// 🚀
const submitBlog = async () => {
const result = await saveBtn();
if (result === 0) {
await router.push('/blog/submit')
}
};
//
const handleCreated = (editor) => {
// console.log('created', editor);
editorRef.value = editor; // editor
};
const handleChange = (editor) => {
// console.log('change:', editor.getHtml());
};
const handleDestroyed = (editor) => {
// console.log('destroyed', editor);
};
const handleFocus = (editor) => {
// console.log('focus', editor);
};
const handleBlur = (editor) => {
// console.log('blur', editor);
};
const customAlert = (info, type) => {
// alert(`${type} - ${info}`);
};
const customPaste = (editor, event, callback) => {
// console.log('ClipboardEvent ', event);
// editor.insertText('xxx');
// callback(false); //
};
//
const insertText = () => {
const editor = editorRef.value;
if (editor == null) return;
editor.insertText('hello world');
};
const printHtml = () => {
const editor = editorRef.value;
if (editor == null) return;
console.log(editor.getHtml());
};
const disable = () => {
const editor = editorRef.value;
if (editor == null) return;
editor.disable();
};
</script>
<style scoped>
:deep(h1) {
font-size: 2rem;
font-weight: bold;
}
.container {
height: calc(100vh - 60px);
width: 100vw;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.submit-btn {
position: absolute;
right: 0;
bottom: 0;
width: 80px;
height: 40px;
border: #ffb74d solid 3px;
border-radius: 5px;
margin: 5px;
opacity: 0.3;
transition: all 0.2s ease;
}
.submit-btn:hover {
opacity: 1;
background: #ffb74d;
}
.el-header {
height: auto;
margin-top: 8px;
padding: 0;
}
.side-bar {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 3px;
//gap: 10px;
}
.side-bar .el-button {
margin: 5px 5px;
}
.el-main {
padding: 0;
}
.editor-container {
max-width: 802px;
min-height: 100px;
width: calc(100% - 5px);
height: calc(100% - 10px);
margin-top: 5px;
display: flex;
word-break: break-word;
flex-direction: column;
border: 1px solid #5d5d5d;
overflow: hidden;
}
.theme-light .editor-container {
border: 1px solid #c4c4c4;
}
.viewer-container {
display: flex;
flex-direction: column;
}
.title {
font-size: 2rem;
font-weight: bold;
margin: 0.67em 0;
padding: 0 10px;
}
.blog-meta {
display: flex;
align-items: center;
gap: 10px;
width: auto;
padding: 0 10px;
margin-bottom: 20px;
}
.avatar, .avatar img {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
}
.viewer {
max-width: 782px;
width: calc(100vw - 119px);
min-height: 100px;
height: calc(100vh - 213px);
word-break: break-word;
border: 1px solid rgba(93, 93, 93, 0.34);
padding: 0 10px;
overflow: auto;
}
</style>
<style>
.w-e-menu-tooltip-v5 {
display: none;
}
div[data-slate-editor] {
max-height: 0;
}
.w-e-text-container {
background: #2a2a2a;
color: #e3e3e3;
}
.theme-light .w-e-text-container {
background: #2a2a2a;
color: #000000;
}
.w-e-toolbar {
background: #1c1c1c;
border-bottom: 1px solid #595959;
}
.w-e-toolbar button {
color: #e5e5e5;
background-color: #262626;
}
.w-e-bar-item button:hover {
color: white;
background-color: #494949;
}
.w-e-bar-item .disabled:hover {
background-color: #494949;
}
.w-e-bar-item-group .w-e-bar-item-menus-container {
background-color: #262626;
border: #464646 solid 1px;
}
.w-e-toolbar svg {
fill: #e5e5e5;
}
.w-e-drop-panel {
background: #262626;
}
.w-e-panel-content-table {
background-color: #262626;
color: white;
}
.w-e-panel-content-table td {
background-color: #262626;
}
.w-e-panel-content-table td.active {
background-color: #494949;
}
#w-e-textarea-1 {
background: #000000;
color: white;
}
.w-e-bar-divider {
background: #595959;
}
.w-e-select-list {
background: #262626;
}
.w-e-select-list ul {
background: #262626;
color: white;
}
.w-e-select-list ul .selected {
background: #494949;
}
.w-e-select-list ul li:hover {
background: #595959;
}
.w-e-drop-panel, .w-e-select-list {
border: #464646 solid 1px;
}
.w-e-panel-content-color li {
border: #464646 solid 1px;
}
.w-e-panel-content-color li .color-block {
border: #464646 solid 1px;
}
.w-e-hover-bar {
background: black;
}
/* 亮色模式 */
.theme-light .w-e-text-container {
background: #ffffff;
}
.theme-light .w-e-toolbar {
background: #f5f5f5;
border-bottom: 1px solid #d4d4d4;
}
.theme-light .w-e-toolbar button {
color: #333333;
background-color: #ebebeb;
}
.theme-light .w-e-bar-item .disabled svg {
fill: #b6b6b6;
}
.theme-light .w-e-bar-item .disabled {
color: #b6b6b6;
}
.theme-light .w-e-bar-item button:hover {
color: black;
background-color: #dcdcdc;
}
.theme-light .w-e-bar-item .disabled:hover {
color: #b6b6b6;
background-color: #dcdcdc;
}
.theme-light .w-e-bar-item-group .w-e-bar-item-menus-container {
background-color: #ebebeb;
border: #d4d4d4 solid 1px;
}
.theme-light .w-e-toolbar svg {
fill: #333333;
}
.theme-light .w-e-drop-panel {
background: #ebebeb;
}
.theme-light .w-e-panel-content-table {
background-color: #ffffff;
color: black;
}
.theme-light .w-e-panel-content-table td {
background-color: #ffffff;
}
.theme-light .w-e-panel-content-table td.active {
background-color: #dcdcdc;
}
.theme-light #w-e-textarea-1 {
background: #ffffff;
color: black;
}
.theme-light .w-e-bar-divider {
background: #d4d4d4;
}
.theme-light .w-e-select-list {
background: #ebebeb;
}
.theme-light .w-e-select-list ul {
background: #ebebeb;
color: black;
}
.theme-light .w-e-select-list ul .selected {
background: #dcdcdc;
}
.theme-light .w-e-select-list ul li:hover {
background: #d4d4d4;
}
.theme-light .w-e-drop-panel, .theme-light .w-e-select-list {
border: #d4d4d4 solid 1px;
}
.theme-light .w-e-panel-content-color li {
border: #d4d4d4 solid 1px;
}
.theme-light .w-e-panel-content-color li .color-block {
border: #d4d4d4 solid 1px;
}
.theme-light .w-e-hover-bar {
background: white;
}
</style>

View File

@ -0,0 +1,153 @@
<script setup>
import {computed, onBeforeUnmount, onMounted, ref} from "vue";
import 'element-plus/theme-chalk/dark/css-vars.css'
import {ChatLineSquare, Edit, Files, Picture, Upload, View} from '@element-plus/icons-vue'
import SubmitBlog_step_interaction from "./SubmitBlog_step_interaction.vue";
import {info} from "sass";
import SubmitBlog_step_tag from "./SubmitBlog_step_tag.vue";
import SubmitBlog_step_check from "./SubmitBlog_step_check.vue";
import SubmitBlog_step_submit from "./SubmitBlog_step_submit.vue";
import store from "../../../store/index.js";
import api from "../../../utils/axios.js";
import swal from "../../../utils/sweetalert.js";
import router from "../../../router/index.js";
const currentProcess = ref(0);
const next = () => {
if (currentProcess.value < 3) currentProcess.value++;
}
const prev = () => {
if (currentProcess.value > 0) currentProcess.value--;
}
const clickPage = (go) => {
if (nonNullKeysCount.value >= go) {
currentProcess.value = go;
}
}
const selectedFunc = (select) => {
const process = ['allowComment', 'tagSelect', 'confirmInfo'];
infoForm.value[process[currentProcess.value]] = select;
}
const infoForm = ref({
allowComment: null,
tagSelect: null,
confirmInfo: null
})
const nonNullKeysCount = computed(() => {
return Object.values(infoForm.value).filter(value => value !== null).length;
});
const submit = async () => {
const formData = new FormData();
formData.append("allowComments", infoForm.value.allowComment);
formData.append("category", infoForm.value.tagSelect);
formData.append("draft", 0);
console.log(Object.fromEntries(formData.entries()));
store.commit('startLoading', '正在上传...');
//
try {
const response = await api.put(`/blogs/${store.state.editStore.currentBlogId}`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
store.commit('stopLoading');
if (response.code === 0) {
currentProcess.value = 4;
await swal.window('success', '上传成功!', '你可以在博客页面看到此博客', '查看博客', '返回账户');
router.push(`/blog/${store.state.editStore.currentBlogId}`)
} else {
swal.tip('error', '上传失败', '请稍后重试');
}
} catch {
store.commit('stopLoading');
swal.tip('error', '上传失败', '请稍后重试');
}
}
const refreshAlert = (event) => {
event.preventDefault();
event.returnValue = ""; //
}
onMounted(() => {
if (! store.state.editStore.currentBlogId) {
swal.tip('error', '未知博客源');
router.back();
}
window.addEventListener("beforeunload", refreshAlert);
})
onBeforeUnmount(() => {
window.removeEventListener("beforeunload", refreshAlert);
})
</script>
<template>
<div class="container">
<el-container>
<el-aside width="200px">
<el-steps direction="vertical" :active="currentProcess" finish-status="success" align-center>
<!-- <el-step title="额外信息" :icon="Edit" @click="clickPage(0)"/>-->
<!-- <el-step title="权限设置" :icon="View" @click="clickPage(1)"/>-->
<el-step title="互动管理" :icon="ChatLineSquare" @click="clickPage(0)"/>
<el-step title="标签选择" :icon="Files" @click="clickPage(1)"/>
<el-step title="确认信息" :icon="Edit" @click="clickPage(2)"/>
<el-step title="上传内容" :icon="Upload" @click="clickPage(3)"/>
</el-steps>
</el-aside>
<el-container>
<el-main v-if="currentProcess === 0">
<SubmitBlog_step_interaction @selected="selectedFunc" :set="infoForm.allowComment"/>
</el-main>
<el-main v-if="currentProcess === 1">
<SubmitBlog_step_tag @selected="selectedFunc" :set="infoForm.tagSelect"/>
</el-main>
<el-main v-if="currentProcess === 2">
<SubmitBlog_step_check @selected="selectedFunc" :set="infoForm.confirmInfo" :info="infoForm"/>
</el-main>
<el-main v-if="currentProcess === 3">
<SubmitBlog_step_submit @selected="submit" :disable="store.state.loading.isLoading"/>
</el-main>
<el-footer>
<el-button @click="prev" :disabled="currentProcess < 1">上一步</el-button>
<el-button v-if="currentProcess !== 3" @click="next" :disabled="nonNullKeysCount < currentProcess + 1">下一步</el-button>
</el-footer>
</el-container>
</el-container>
</div>
</template>
<style scoped>
.container {
display: flex;
flex-direction: row;
align-items: flex-start;
max-width: 1150px;
width: 100%;
height: calc(100vh - 60px);
}
.el-container {
height: 100%;
}
.el-steps {
height: calc(100% - 20px);
width: 150px;
padding: 20px 0 0 30px;
cursor: default;
}
.el-footer {
display: flex;
justify-content: space-between;
}
</style>

View File

@ -0,0 +1,44 @@
<script setup>
import {ref} from "vue";
const props = defineProps({
set: {
type: Boolean
},
info: {
type: Object
}
}
)
const isChecked = ref(props.set || null);
const emit = defineEmits(['selected']);
const sendMessage = () => {
emit('selected', isChecked);
};
</script>
<template>
<el-container>
<div class="title">请确认信息</div>
<ul>
<li>{{ info.allowComment ? '' : '不' }}允许评论</li>
<li>分类: {{ info.tagSelect }}</li>
</ul>
<el-radio v-model="isChecked" label="确认" @change="sendMessage"/>
</el-container>
</template>
<style scoped>
.el-container {
flex-direction: column;
gap: 20px;
}
.title {
font-size: 30px;
}
</style>

View File

@ -0,0 +1,37 @@
<script setup>
import {ref} from "vue";
const props = defineProps({
set: {
type: Boolean
}}
)
const allowComment = ref(props.set || null);
const emit = defineEmits(['selected']);
const sendMessage = () => {
emit('selected', allowComment);
};
</script>
<template>
<el-container>
<div class="title">是否允许评论</div>
<el-radio-group v-model="allowComment" class="ml-4" @change="sendMessage">
<el-radio :label="true" size="large"></el-radio>
<el-radio :label="false" size="large"></el-radio>
</el-radio-group>
</el-container>
</template>
<style scoped>
.el-container {
flex-direction: column;
gap: 20px;
}
.title {
font-size: 30px;
}
</style>

View File

@ -0,0 +1,33 @@
<script setup>
import {ref} from "vue";
const props = defineProps({
disable: {
type: Boolean
},
}
)
const emit = defineEmits(['selected']);
const sendMessage = () => {
emit('selected', true);
};
</script>
<template>
<el-container>
<div class="title">点击提交博客</div>
<el-button type="primary" @click="sendMessage" :disabled="disable">提交</el-button>
</el-container>
</template>
<style scoped>
.el-container {
flex-direction: column;
gap: 20px;
}
.title {
font-size: 30px;
}
</style>

View File

@ -0,0 +1,93 @@
<template>
<el-container>
<div class="title">选择或新建分类</div>
<el-autocomplete
v-model="selectedCategory"
:fetch-suggestions="querySearchAsync"
:placeholder="isLoading ? '正在加载...' : '请选择或输入分类'"
@select="handleSelect"
@keyup.enter="handleEnter"
:clearable="true"
>
<template #suffix>
<el-icon v-if="isLoading">
<Loading />
</el-icon>
</template>
</el-autocomplete>
</el-container>
</template>
<script setup>
import { ref, onMounted, defineEmits } from 'vue'
import api from "../../../utils/axios.js";
import {Loading} from "@element-plus/icons-vue";
const props = defineProps({
set: {
type: String
}}
)
const isLoading = ref(false);
const selectedCategory = ref(props.set || '')
const categories = ref([]) //
const emit = defineEmits(['selected']) //
// API
const fetchCategories = async () => {
isLoading.value = true;
try {
const response = await api.get('/blogs/categories')
if (response.code === 0 && Array.isArray(response.categories)) {
categories.value = response.categories.map(category => ({
value: category
}))
}
} catch (error) {
console.error('获取分类失败:', error)
}
isLoading.value = false;
}
//
const querySearchAsync = (queryString, cb) => {
let results = categories.value.filter(item => item.value.includes(queryString))
//
if (queryString && !categories.value.some(item => item.value === queryString)) {
results.push({value: queryString + ' (新建)'})
}
cb(results)
}
//
const handleSelect = (item) => {
let category = item.value.replace(' (新建)', '') //
emit('selected', category) //
}
//
const handleEnter = () => {
if (selectedCategory.value && !categories.value.some(item => item.value === selectedCategory.value)) {
const newCategory = {value: selectedCategory.value}
categories.value.push(newCategory) //
emit('selected', selectedCategory.value) //
}
}
//
onMounted(fetchCategories)
</script>
<style scoped>
.el-container {
flex-direction: column;
gap: 20px;
}
.title {
font-size: 30px;
}
</style>

View File

@ -0,0 +1,31 @@
<script setup>
import {computed, onBeforeUnmount, onMounted, ref} from "vue";
import store from "../../../store/index.js";
onMounted(() => {
store.commit('hideNavBar');
});
onBeforeUnmount(() => {
store.commit('showNavBar');
})
</script>
<template>
<div class="container">
<iframe ref="iframeRef" src="/static/isolatedPages/gungame3d/index.html" width="100%" height="100%"></iframe>
</div>
</template>
<style scoped>
.container {
width: 100%;
height: calc(100vh + 20px);
padding: 0;
}
iframe {
border: none;
width: 100%;
height: 100%;
margin-bottom: 20px;
}
</style>

View File

@ -107,8 +107,7 @@ async function clickRefresh() {
}
pageLoading.value = false;
}
let interval = null; //
let interval;
function startCountdown() {
if (interval) clearInterval(interval); //
@ -132,15 +131,8 @@ watch(sendCD, (newValue) => {
onMounted(async () => {
await refreshBoard();
timer = setInterval(refreshBoard, 7000);
});
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer);
timer = null; //
}
});
</script>

View File

@ -44,8 +44,8 @@ async function deleteMessage(id, message) {
<div class="content">{{ message.content }}</div>
<div class="bottom">
<div class="time">{{ message.date }}</div>
<like_button :active="message.content.length%2===0" :amount="message.likes" :toggle-func="async (a)=>{
console.log(message.id, a)}"/>
<!-- <like_button :active="message.content.length%2===0" :amount="message.likes" :toggle-func="async (a)=>{-->
<!-- console.log(message.id, a)}"/>-->
</div>
</div>
<div class="right">

View File

@ -27,11 +27,14 @@ import PdfEx_page from "../pages/toolPages/pdfExtractor/pdfEx_page.vue";
import RequestTester_page from "../pages/toolPages/RequestTester/requestTester_page.vue";
import About from "../pages/About.vue";
import Editor from "../pages/Editor.vue";
import Editor from "../pages/blogPages/blogEditor.vue";
import NotFound from "../pages/errorPages/notFound.vue";
import Test_page from "../pages/Test_page.vue";
import SingleBlog_page from "../pages/blogPages/SingleBlog_page.vue";
import SubmitBlog_page from "../pages/blogPages/submitBlogPages/SubmitBlog_page.vue";
import swal from "../utils/sweetalert.js";
import GunGame_page from "../pages/demoPages/gunGame/gunGame_page.vue";
const routes = [
{path: '/404',
@ -59,6 +62,11 @@ const routes = [
component: SingleBlog_page,
meta: {title: '博客'}
}, {
path: '/blog/submit',
name: 'SubmitBlog',
component: SubmitBlog_page,
meta: {title: '发布博客'}
},{
path: '/projects',
name: 'Projects',
component: Projects,
@ -81,7 +89,12 @@ const routes = [
children: [
{path: "quiz", component: Pod_quiz}
]
}
},
{
path: "gungame3d",
component: GunGame_page,
meta: {title: '打枪 '},
},
]
}, {
path: '/tools',
@ -132,7 +145,7 @@ const router = createRouter({
routes
});
router.beforeEach((to, from, next) => {
router.beforeEach(async (to, from, next) => {
if (!store.state.userInfo.uid && store.state.token) {
AuthService.setSelfInfo();
}
@ -149,6 +162,15 @@ router.beforeEach((to, from, next) => {
next('/account');
}
if ((from.path === '/editor' && store.state.editStore.isEditing && to.path !== '/blog/submit') ||
(from.path === '/blog/submit' && store.state.sessionStore.currentBlogId)
) {
const result = await swal.window('info', '确定要退出吗?', '未保存的内容会丢失', '确认', '返回');
if (!result.isConfirmed) {
return;
}
}
if (to.matched.length === 0) {
next('/404');
}

View File

@ -7,12 +7,17 @@ const store = createStore({
state: {
theme: localStorage.getItem('theme') || 'dark',
loading: {},
navBar: {
display: true
},
token: null,
userInfo: {},
editStore: {},
editAutoSave: {on: true, interval: 30000},
demosLocal: {},
sessionStore: {},
sessionStore: {
account: {}
},
},
mutations: {
toggleTheme(state) {
@ -46,11 +51,17 @@ const store = createStore({
state.sessionStore[obj.key] = {...state.sessionStore[obj.key], ...obj.value}
},
deleteSessionValue(state, obj) {
if (typeof obj === 'string') {
delete state.sessionStore[obj.key];
}
delete state.sessionStore[obj.key][obj.value];
},
saveEdit(state, obj) {
state.editStore = {...state.editStore, ...obj};
},
cleanEditStore(state) {
state.editStore = {};
},
setLogTemp(state, arr) {
state.log = arr;
},
@ -59,7 +70,13 @@ const store = createStore({
},
setAutoSaveTime(state, ms) {
state.editAutoSave.interval = Number(ms) || 30000;
}
},
showNavBar(state) {
state.navBar.display = true;
},
hideNavBar(state) {
state.navBar.display = false;
},
},
getters: {
currentTheme: state => state.theme,

View File

@ -89,6 +89,10 @@ code {
border: 2px solid #ddd;
}
.el-tooltip__trigger:focus-visible {
outline: unset;
}
/*::-webkit-scrollbar {*/
/* display: none;*/
/*}*/
@ -132,3 +136,20 @@ code {
.theme-light ::-webkit-scrollbar-thumb:hover {
background-color: #aaa;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease, transform 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(20px); /* 从下方 20px 进入 */
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
transform: translateY(0);
}

91
src/utils/formatTime.js Normal file
View File

@ -0,0 +1,91 @@
export function formatGMTToLocal(timeString, sub) {
try {
if (!timeString || typeof timeString !== "string") {
throw new Error("Invalid input: timeString must be a non-empty string.");
}
const date = new Date(timeString);
if (isNaN(date.getTime())) {
throw new Error("Invalid date format.");
}
// 转换为北京时间UTC+8
const beijingTime = new Date(date.getTime() - 8 * 60 * 60 * 1000);
// 格式化时间
const year = beijingTime.getFullYear();
const month = String(beijingTime.getMonth() + 1).padStart(2, "0");
const day = String(beijingTime.getDate()).padStart(2, "0");
const hours = String(beijingTime.getHours()).padStart(2, "0");
const minutes = String(beijingTime.getMinutes()).padStart(2, "0");
const seconds = String(beijingTime.getSeconds()).padStart(2, "0");
const result = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
return result.substring(0, result.length - sub);
} catch (error) {
return "未知时间";
}
}
export function getCurrentISODateTime() {
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
}
export function timeDifference(dateString) {
try {
if (!dateString || typeof dateString !== "string") {
throw new Error("Invalid input: dateString must be a non-empty string.");
}
let inputDate = new Date(dateString);
if (isNaN(inputDate.getTime())) {
// 尝试转换 "yyyy-mm-dd hh:mm:ss" 格式
inputDate = new Date(dateString.replace(" ", "T"));
if (isNaN(inputDate.getTime())) {
throw new Error("Invalid date format.");
}
}
const now = new Date();
const diffInMs = now - inputDate;
const diffInSeconds = Math.floor(diffInMs / 1000);
const diffInMinutes = Math.floor(diffInSeconds / 60);
const diffInHours = Math.floor(diffInMinutes / 60);
const diffInDays = Math.floor(diffInHours / 24);
const diffInMonths = Math.floor(diffInDays / 30); // Approximation
const diffInYears = Math.floor(diffInDays / 365); // Approximation
console.log({diffInMs,diffInSeconds ,diffInMinutes ,diffInHours ,diffInDays })
if (diffInSeconds < 0) {
return `刚刚`;
} else if (diffInSeconds < 60) {
return `${diffInSeconds}秒前`;
} else if (diffInMinutes < 60) {
return `${diffInMinutes}分钟前`;
} else if (diffInHours < 24) {
return `${diffInHours}小时前`;
} else if (diffInDays < 30) {
return `${diffInDays}天前`;
} else if (diffInMonths < 12) {
const monthAndDay = `${inputDate.getMonth() + 1}${inputDate.getDate()}`;
return now.getFullYear() === inputDate.getFullYear()
? `${monthAndDay}`
: `${inputDate.getFullYear()}${monthAndDay}`;
} else {
return `${inputDate.getFullYear()}${inputDate.getMonth() + 1}${inputDate.getDate()}`;
}
} catch (error) {
// console.error("timeDifference Error:", error.message);
return "未知时间";
}
}

View File

@ -0,0 +1,18 @@
import {getDomain} from "./getDomain.js";
import api from "./axios.js";
export async function getInfoWithPages(url, page, size, other) {
const otherKeys = Object.keys(other || []);
let queryURL = url + `?page=${page || 1}&size=${size || 5}`;
otherKeys.forEach(key => {
queryURL += `&${key}=${other[key]}`;
})
try {
const response = await api.get(queryURL);
return response;
} catch {
return {code: 3};
}
}

View File

@ -3,12 +3,14 @@ import Swal from 'sweetalert2';
const swalInstantiations = {
tip: Swal.mixin({
toast: true, // 弹窗类型为 Toast
position: 'top', // 弹窗显示位置
toast: true, // 以 Toast 形式展示
position: 'top', // 弹出位置
showConfirmButton: false, // 不显示确认按钮
timer: 3000, // 弹窗显示3秒后自动关闭
showCloseButton: true, // ✅ 添加关闭按钮
timer: 3000, // 自动关闭时间 3 秒
// timerProgressBar: true, // 显示倒计时进度条
didOpen: (toast) => {
Swal.getPopup().style.marginTop = '60px';
toast.addEventListener('mouseenter', Swal.stopTimer);
toast.addEventListener('mouseleave', Swal.resumeTimer);
}
@ -21,6 +23,7 @@ const swalInstantiations = {
confirmButtonText: '删除',
cancelButtonText: '取消',
didOpen: (toast) => {
Swal.getPopup().style.marginTop = '0';
toast.addEventListener('mouseenter', Swal.stopTimer);
toast.addEventListener('mouseleave', Swal.resumeTimer);
}

View File

@ -1,7 +1,37 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [vue()],
})
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "./src/assets/styles/element/index.scss";` // 你的全局 SCSS 文件路径
}
}
},
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
})