feat: update ui
This commit is contained in:
@@ -4,21 +4,18 @@
|
||||
"": {
|
||||
"name": "wechat",
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.0.3",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"pinia": "^3.0.2",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.3",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"vue": "^3.5.13",
|
||||
"vue-icons-plus": "^0.1.8",
|
||||
"vue-router": "^4.5.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"vite": "^6.3.0",
|
||||
},
|
||||
},
|
||||
@@ -84,18 +81,6 @@
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@primeuix/styled": ["@primeuix/styled@0.5.1", "https://registry.npmmirror.com/@primeuix/styled/-/styled-0.5.1.tgz", { "dependencies": { "@primeuix/utils": "^0.5.3" } }, "sha512-5Ftw/KSauDPClQ8F2qCyCUF7cIUEY4yLNikf0rKV7Vsb8zGYNK0dahQe7CChaR6M2Kn+NA2DSBSk76ZXqj6Uog=="],
|
||||
|
||||
"@primeuix/styles": ["@primeuix/styles@1.0.3", "https://registry.npmmirror.com/@primeuix/styles/-/styles-1.0.3.tgz", { "dependencies": { "@primeuix/styled": "^0.5.1" } }, "sha512-yHj/Q+fosJ1736Ty5lRbpqhKa9piou+xZPPppNHUDshq0+XhrFwDGggvPGmDAJyUIM+ChM/Nj8lPY/AwTNXAkg=="],
|
||||
|
||||
"@primeuix/themes": ["@primeuix/themes@1.0.3", "https://registry.npmmirror.com/@primeuix/themes/-/themes-1.0.3.tgz", { "dependencies": { "@primeuix/styled": "^0.5.1" } }, "sha512-f/1qadrv5TFMHfvtVv4Y9zjrkeDP2BO/cuzbHBO9DYxKL6YBIPT9BjKec2K4Kg8PcfGm6CAvxAvICadJSWejRw=="],
|
||||
|
||||
"@primeuix/utils": ["@primeuix/utils@0.5.3", "https://registry.npmmirror.com/@primeuix/utils/-/utils-0.5.3.tgz", {}, "sha512-7SGh7734wcF1/uK6RzO6Z6CBjGQ97GDHfpyl2F1G/c7R0z9hkT/V72ypDo82AWcCS7Ta07oIjDpOCTkSVZuEGQ=="],
|
||||
|
||||
"@primevue/core": ["@primevue/core@4.3.3", "https://registry.npmmirror.com/@primevue/core/-/core-4.3.3.tgz", { "dependencies": { "@primeuix/styled": "^0.5.0", "@primeuix/utils": "^0.5.1" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-kSkN5oourG7eueoFPIqiNX3oDT/f0I5IRK3uOY/ytz+VzTZp5yuaCN0Nt42ZQpVXjDxMxDvUhIdaXVrjr58NhQ=="],
|
||||
|
||||
"@primevue/icons": ["@primevue/icons@4.3.3", "https://registry.npmmirror.com/@primevue/icons/-/icons-4.3.3.tgz", { "dependencies": { "@primeuix/utils": "^0.5.1", "@primevue/core": "4.3.3" } }, "sha512-ouQaxHyeFB6MSfEGGbjaK5Qv9efS1xZGetZoU5jcPm090MSYLFtroP1CuK3lZZAQals06TZ6T6qcoNukSHpK5w=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.40.0", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.40.0", "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w=="],
|
||||
@@ -286,10 +271,6 @@
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"primeicons": ["primeicons@7.0.0", "https://registry.npmmirror.com/primeicons/-/primeicons-7.0.0.tgz", {}, "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw=="],
|
||||
|
||||
"primevue": ["primevue@4.3.3", "https://registry.npmmirror.com/primevue/-/primevue-4.3.3.tgz", { "dependencies": { "@primeuix/styled": "^0.5.0", "@primeuix/styles": "^1.0.0", "@primeuix/utils": "^0.5.1", "@primevue/core": "4.3.3", "@primevue/icons": "4.3.3" } }, "sha512-nooYVoEz5CdP3EhUkD6c3qTdRmpLHZh75fBynkUkl46K8y5rksHTjdSISiDijwTA5STQIOkyqLb+RM+HQ6nC1Q=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rollup": ["rollup@4.40.0", "https://registry.npmmirror.com/rollup/-/rollup-4.40.0.tgz", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.40.0", "@rollup/rollup-android-arm64": "4.40.0", "@rollup/rollup-darwin-arm64": "4.40.0", "@rollup/rollup-darwin-x64": "4.40.0", "@rollup/rollup-freebsd-arm64": "4.40.0", "@rollup/rollup-freebsd-x64": "4.40.0", "@rollup/rollup-linux-arm-gnueabihf": "4.40.0", "@rollup/rollup-linux-arm-musleabihf": "4.40.0", "@rollup/rollup-linux-arm64-gnu": "4.40.0", "@rollup/rollup-linux-arm64-musl": "4.40.0", "@rollup/rollup-linux-loongarch64-gnu": "4.40.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0", "@rollup/rollup-linux-riscv64-gnu": "4.40.0", "@rollup/rollup-linux-riscv64-musl": "4.40.0", "@rollup/rollup-linux-s390x-gnu": "4.40.0", "@rollup/rollup-linux-x64-gnu": "4.40.0", "@rollup/rollup-linux-x64-musl": "4.40.0", "@rollup/rollup-win32-arm64-msvc": "4.40.0", "@rollup/rollup-win32-ia32-msvc": "4.40.0", "@rollup/rollup-win32-x64-msvc": "4.40.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w=="],
|
||||
@@ -302,8 +283,6 @@
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.4", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.4.tgz", {}, "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A=="],
|
||||
|
||||
"tailwindcss-primeui": ["tailwindcss-primeui@0.6.1", "https://registry.npmmirror.com/tailwindcss-primeui/-/tailwindcss-primeui-0.6.1.tgz", { "peerDependencies": { "tailwindcss": ">=3.1.0" } }, "sha512-T69Rylcrmnt8zy9ik+qZvsLuRIrS9/k6rYJSIgZ1trnbEzGDDQSCIdmfyZknevqiHwpSJHSmQ9XT2C+S/hJY4A=="],
|
||||
|
||||
"tapable": ["tapable@2.2.1", "https://registry.npmmirror.com/tapable/-/tapable-2.2.1.tgz", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.12", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.12.tgz", { "dependencies": { "fdir": "^6.4.3", "picomatch": "^4.0.2" } }, "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww=="],
|
||||
@@ -314,6 +293,8 @@
|
||||
|
||||
"vue": ["vue@3.5.13", "https://registry.npmmirror.com/vue/-/vue-3.5.13.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.13", "@vue/compiler-sfc": "3.5.13", "@vue/runtime-dom": "3.5.13", "@vue/server-renderer": "3.5.13", "@vue/shared": "3.5.13" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ=="],
|
||||
|
||||
"vue-icons-plus": ["vue-icons-plus@0.1.8", "https://registry.npmmirror.com/vue-icons-plus/-/vue-icons-plus-0.1.8.tgz", { "peerDependencies": { "vue": ">=2.7.0" } }, "sha512-Xc4hDsD/oP9waSUf44nSaFBhUPo+QkpKclx0S7//5BRACpXymctbit02epek0VRW6nb81pR486XmxPP/ofm2yQ=="],
|
||||
|
||||
"vue-router": ["vue-router@4.5.0", "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.0.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "https://registry.npmmirror.com/@emnapi/core/-/core-1.4.3.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
{
|
||||
"name": "wechat",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.0.3",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"pinia": "^3.0.2",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.3",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
}
|
||||
"name": "wechat",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"pinia": "^3.0.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"vue": "^3.5.13",
|
||||
"vue-icons-plus": "^0.1.8",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.3",
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
}
|
||||
65
frontend/wechat/src/components/ArticleListItem.vue
Normal file
65
frontend/wechat/src/components/ArticleListItem.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { computed, defineProps } from 'vue'
|
||||
import { AiOutlineEye, AiOutlineLike } from 'vue-icons-plus/ai'
|
||||
|
||||
const props = defineProps({
|
||||
article: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return new Date(props.article.created_at).toLocaleDateString()
|
||||
})
|
||||
|
||||
const discountPrice = computed(() => {
|
||||
return (props.article.price * props.article.discount / 100).toFixed(2)
|
||||
})
|
||||
|
||||
const mediaTypes = computed(() => {
|
||||
return [...new Set(props.article.assets.map(asset => asset.type))]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-4 p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow cursor-pointer">
|
||||
<!-- 缩略图占位 -->
|
||||
<div class="w-24 h-24 flex-shrink-0 bg-gray-100 rounded-lg"></div>
|
||||
|
||||
<div class="flex-1 flex flex-col">
|
||||
<h3 class="text-lg font-medium mb-1">{{ article.title }}</h3>
|
||||
<p class="text-gray-600 text-sm line-clamp-2 mb-2">{{ article.description }}</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<span v-for="tag in article.tags" :key="tag"
|
||||
class="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded-full">
|
||||
{{ tag }}
|
||||
</span>
|
||||
<span v-for="type in mediaTypes" :key="type"
|
||||
class="px-2 py-0.5 text-xs bg-blue-100 text-blue-600 rounded-full">
|
||||
{{ type }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-auto text-sm">
|
||||
<div class="flex items-center gap-3 text-gray-500">
|
||||
<span>{{ formattedDate }}</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<AiOutlineEye class="w-4 h-4" />
|
||||
{{ article.views }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<AiOutlineLike class="w-4 h-4" />
|
||||
{{ article.likes }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-bold text-red-500">¥{{ discountPrice }}</span>
|
||||
<span class="text-sm line-through text-gray-400">¥{{ article.price }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineProps({
|
||||
msg: String,
|
||||
})
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank">create-vue</a>, the official Vue + Vite
|
||||
starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support" target="_blank">Vue Docs Scaling up
|
||||
Guide</a>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +1,26 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { AiOutlineHome, AiOutlineShoppingCart, AiOutlineUser } from 'vue-icons-plus/ai'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const activeTab = ref(0)
|
||||
|
||||
const tabs = [
|
||||
{ label: '列表', route: '/' },
|
||||
{ label: '已购买', route: '/purchased' },
|
||||
{ label: '我的', route: '/profile' }
|
||||
{ label: '列表', route: '/', icon: AiOutlineHome },
|
||||
{ label: '已购买', route: '/purchased', icon: AiOutlineShoppingCart },
|
||||
{ label: '我的', route: '/profile', icon: AiOutlineUser }
|
||||
]
|
||||
|
||||
const switchTab = (index) => {
|
||||
const switchTab = (index, route) => {
|
||||
activeTab.value = index
|
||||
router.push(tabs[index].route)
|
||||
router.push(route)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<div class="content">
|
||||
<div class="h-screen flex flex-col bg-gray-50">
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive>
|
||||
<component :is="Component" />
|
||||
@@ -27,23 +28,22 @@ const switchTab = (index) => {
|
||||
</router-view>
|
||||
</div>
|
||||
|
||||
<TabMenu :model="tabs" :activeIndex="activeTab" @tab-change="switchTab" class="bottom-tabs" />
|
||||
<nav class="flex-none bg-white border-t border-gray-200">
|
||||
<div class="flex justify-around items-center h-14">
|
||||
<button v-for="(tab, index) in tabs" :key="index"
|
||||
class="flex flex-col items-center justify-center w-full h-full py-1 focus:outline-none" :class="[
|
||||
activeTab === index
|
||||
? 'text-blue-600'
|
||||
: 'text-gray-600 hover:text-blue-600 active:text-blue-700'
|
||||
]" @click="switchTab(index, tab.route)">
|
||||
<component :is="tab.icon" class="w-6 h-6" />
|
||||
<span class="mt-1 text-xs">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bottom-tabs {
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
/* Remove all existing styles */
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import { createPinia } from 'pinia';
|
||||
import PrimeVue from 'primevue/config';
|
||||
import ConfirmationService from 'primevue/confirmationservice';
|
||||
import ToastService from 'primevue/toastservice';
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { router } from './router.js';
|
||||
|
||||
// Import only the required PrimeVue styles
|
||||
import 'primeicons/primeicons.css'; // Icons
|
||||
import './style.css';
|
||||
|
||||
|
||||
@@ -17,24 +10,5 @@ const app = createApp(App)
|
||||
|
||||
const pinia = createPinia()
|
||||
app.use(pinia);
|
||||
|
||||
app.use(router);
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
},
|
||||
ripple: true,
|
||||
options: {
|
||||
darkModeSelector: '.my-app-dark',
|
||||
}
|
||||
})
|
||||
|
||||
// Register the ConfirmationService to fix the error
|
||||
app.use(ConfirmationService);
|
||||
|
||||
// Register the ToastService
|
||||
app.use(ToastService);
|
||||
|
||||
// Remove global component registrations
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -24,7 +24,7 @@ const routes = [
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/article/:id',
|
||||
path: '/posts/:id',
|
||||
name: 'article-detail',
|
||||
component: () => import('@/views/ArticleDetail.vue')
|
||||
}
|
||||
|
||||
@@ -14,10 +14,11 @@ export const useArticleStore = defineStore('article', {
|
||||
if (this.loading || !this.hasMore) return
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await fetch(`/api/articles?page=${this.page}&q=${this.searchQuery}`)
|
||||
const response = await fetch(`/api/posts?page=${this.page}&q=${this.searchQuery}`)
|
||||
const data = await response.json()
|
||||
this.articles = [...this.articles, ...data.items]
|
||||
this.hasMore = data.hasMore
|
||||
const items = data.items || []
|
||||
this.articles = [...this.articles, ...items]
|
||||
this.hasMore = items.length > 0 // 只有当返回的列表为空时,才设置 hasMore 为 false
|
||||
this.page++
|
||||
} finally {
|
||||
this.loading = false
|
||||
|
||||
297
frontend/wechat/src/stores/article_list.json
Normal file
297
frontend/wechat/src/stores/article_list.json
Normal file
@@ -0,0 +1,297 @@
|
||||
{
|
||||
"page": 7,
|
||||
"limit": 10,
|
||||
"total": 100,
|
||||
"items": [
|
||||
{
|
||||
"id": 40,
|
||||
"created_at": "2025-04-11T15:03:25.666419Z",
|
||||
"updated_at": "2025-04-11T15:03:25.666419Z",
|
||||
"deleted_at": null,
|
||||
"status": 1,
|
||||
"title": "test-title-39",
|
||||
"description": "test-description-39",
|
||||
"content": "test-content-39",
|
||||
"price": 6747,
|
||||
"discount": 98,
|
||||
"views": 8091,
|
||||
"likes": 9914,
|
||||
"tags": [
|
||||
"tag1",
|
||||
"tag2",
|
||||
"tag3"
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"type": "audio",
|
||||
"media": 8143
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"media": 2977
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"created_at": "2025-04-11T15:03:25.661237Z",
|
||||
"updated_at": "2025-04-11T15:03:25.661237Z",
|
||||
"deleted_at": null,
|
||||
"status": 1,
|
||||
"title": "test-title-38",
|
||||
"description": "test-description-38",
|
||||
"content": "test-content-38",
|
||||
"price": 2891,
|
||||
"discount": 45,
|
||||
"views": 4462,
|
||||
"likes": 6110,
|
||||
"tags": [
|
||||
"tag1",
|
||||
"tag2",
|
||||
"tag3"
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"type": "audio",
|
||||
"media": 5431
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"media": 3866
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"created_at": "2025-04-11T15:03:25.657112Z",
|
||||
"updated_at": "2025-04-11T15:03:25.657112Z",
|
||||
"deleted_at": null,
|
||||
"status": 1,
|
||||
"title": "test-title-37",
|
||||
"description": "test-description-37",
|
||||
"content": "test-content-37",
|
||||
"price": 904,
|
||||
"discount": 70,
|
||||
"views": 8988,
|
||||
"likes": 816,
|
||||
"tags": [
|
||||
"tag1",
|
||||
"tag2",
|
||||
"tag3"
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"type": "audio",
|
||||
"media": 3307
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"media": 2933
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"created_at": "2025-04-11T15:03:25.65266Z",
|
||||
"updated_at": "2025-04-11T15:03:25.65266Z",
|
||||
"deleted_at": null,
|
||||
"status": 1,
|
||||
"title": "test-title-36",
|
||||
"description": "test-description-36",
|
||||
"content": "test-content-36",
|
||||
"price": 4476,
|
||||
"discount": 45,
|
||||
"views": 9910,
|
||||
"likes": 9295,
|
||||
"tags": [
|
||||
"tag1",
|
||||
"tag2",
|
||||
"tag3"
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"type": "audio",
|
||||
"media": 3472
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"media": 3283
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"created_at": "2025-04-11T15:03:25.644946Z",
|
||||
"updated_at": "2025-04-11T15:03:25.644946Z",
|
||||
"deleted_at": null,
|
||||
"status": 1,
|
||||
"title": "test-title-35",
|
||||
"description": "test-description-35",
|
||||
"content": "test-content-35",
|
||||
"price": 7397,
|
||||
"discount": 56,
|
||||
"views": 2486,
|
||||
"likes": 673,
|
||||
"tags": [
|
||||
"tag1",
|
||||
"tag2",
|
||||
"tag3"
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"type": "audio",
|
||||
"media": 1183
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"media": 9735
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"created_at": "2025-04-11T15:03:25.640256Z",
|
||||
"updated_at": "2025-04-11T15:03:25.640256Z",
|
||||
"deleted_at": null,
|
||||
"status": 1,
|
||||
"title": "test-title-34",
|
||||
"description": "test-description-34",
|
||||
"content": "test-content-34",
|
||||
"price": 3733,
|
||||
"discount": 15,
|
||||
"views": 3705,
|
||||
"likes": 1863,
|
||||
"tags": [
|
||||
"tag1",
|
||||
"tag2",
|
||||
"tag3"
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"type": "audio",
|
||||
"media": 8713
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"media": 9442
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"created_at": "2025-04-11T15:03:25.634088Z",
|
||||
"updated_at": "2025-04-11T15:03:25.634088Z",
|
||||
"deleted_at": null,
|
||||
"status": 1,
|
||||
"title": "test-title-33",
|
||||
"description": "test-description-33",
|
||||
"content": "test-content-33",
|
||||
"price": 6591,
|
||||
"discount": 12,
|
||||
"views": 3834,
|
||||
"likes": 7400,
|
||||
"tags": [
|
||||
"tag1",
|
||||
"tag2",
|
||||
"tag3"
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"type": "audio",
|
||||
"media": 7429
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"media": 9401
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"created_at": "2025-04-11T15:03:25.628572Z",
|
||||
"updated_at": "2025-04-11T15:03:25.628572Z",
|
||||
"deleted_at": null,
|
||||
"status": 1,
|
||||
"title": "test-title-32",
|
||||
"description": "test-description-32",
|
||||
"content": "test-content-32",
|
||||
"price": 3740,
|
||||
"discount": 92,
|
||||
"views": 7324,
|
||||
"likes": 3705,
|
||||
"tags": [
|
||||
"tag1",
|
||||
"tag2",
|
||||
"tag3"
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"type": "audio",
|
||||
"media": 8942
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"media": 2618
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"created_at": "2025-04-11T15:03:25.623885Z",
|
||||
"updated_at": "2025-04-11T15:03:25.623885Z",
|
||||
"deleted_at": null,
|
||||
"status": 1,
|
||||
"title": "test-title-31",
|
||||
"description": "test-description-31",
|
||||
"content": "test-content-31",
|
||||
"price": 1839,
|
||||
"discount": 37,
|
||||
"views": 4750,
|
||||
"likes": 2995,
|
||||
"tags": [
|
||||
"tag1",
|
||||
"tag2",
|
||||
"tag3"
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"type": "audio",
|
||||
"media": 7409
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"media": 8843
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"created_at": "2025-04-11T15:03:25.61745Z",
|
||||
"updated_at": "2025-04-11T15:03:25.61745Z",
|
||||
"deleted_at": null,
|
||||
"status": 1,
|
||||
"title": "test-title-30",
|
||||
"description": "test-description-30",
|
||||
"content": "test-content-30",
|
||||
"price": 2297,
|
||||
"discount": 56,
|
||||
"views": 715,
|
||||
"likes": 1224,
|
||||
"tags": [
|
||||
"tag1",
|
||||
"tag2",
|
||||
"tag3"
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"type": "audio",
|
||||
"media": 9268
|
||||
},
|
||||
{
|
||||
"type": "video",
|
||||
"media": 4416
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss-primeui";
|
||||
|
||||
@custom-variant dark (&:where(.my-app-dark, .my-app-dark *));
|
||||
@import "tailwindcss";
|
||||
@@ -14,14 +14,31 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="article-detail p-3">
|
||||
<Button icon="pi pi-arrow-left" @click="router.back()" class="p-button-text mb-3" />
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<header class="fixed top-0 left-0 right-0 h-14 bg-white border-b border-gray-200 flex items-center px-4 z-50">
|
||||
<button @click="router.back()"
|
||||
class="flex items-center justify-center w-10 h-10 mr-2 rounded-full hover:bg-gray-100 active:bg-gray-200 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 class="text-lg font-medium">{{ article?.title || '文章详情' }}</h2>
|
||||
</header>
|
||||
|
||||
<div v-if="article">
|
||||
<h1>{{ article.title }}</h1>
|
||||
<div class="content mt-3">
|
||||
{{ article.content }}
|
||||
<main class="pt-14 px-4">
|
||||
<div v-if="article" class="py-4">
|
||||
<div class="prose max-w-none">
|
||||
{{ article.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex justify-center items-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-blue-600"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 可以移除所有样式,因为都使用了 Tailwind 类 */
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import ArticleListItem from '@/components/ArticleListItem.vue'
|
||||
import { useArticleStore } from '@/stores/article'
|
||||
import { useScroll } from '@vueuse/core'; // Changed to useScroll as a simpler alternative
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -9,29 +10,36 @@ const store = useArticleStore()
|
||||
const searchInput = ref('')
|
||||
const loadingTrigger = ref(null)
|
||||
|
||||
const el = ref(null)
|
||||
const { y, isScrolling } = useScroll(el)
|
||||
|
||||
watch(y, (newY) => {
|
||||
if (!store.loading && !isScrolling && newY > 0) {
|
||||
const scrollHeight = el.value.scrollHeight
|
||||
const scrollTop = newY
|
||||
const clientHeight = el.value.clientHeight
|
||||
|
||||
if (scrollHeight - scrollTop - clientHeight < 50) {
|
||||
// 优化 Intersection Observer 配置
|
||||
useIntersectionObserver(
|
||||
loadingTrigger,
|
||||
([{ isIntersecting }]) => {
|
||||
console.log('Intersection state:', { isIntersecting, loading: store.loading, hasMore: store.hasMore })
|
||||
if (isIntersecting && !store.loading && store.hasMore) {
|
||||
console.log('Fetching more articles...')
|
||||
store.fetchArticles()
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: 0,
|
||||
rootMargin: '100px' // 提前 100px 触发加载
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const showArticle = (id) => {
|
||||
router.push(`/article/${id}`)
|
||||
router.push(`/posts/${id}`)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
store.setSearchQuery(searchInput.value)
|
||||
}
|
||||
|
||||
const handleKeyup = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (store.articles.length === 0) {
|
||||
store.fetchArticles()
|
||||
@@ -40,21 +48,36 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="article-list" ref="el">
|
||||
<div class="search-bar p-2">
|
||||
<InputText v-model="searchInput" placeholder="搜索文章" class="w-full" />
|
||||
<Button @click="handleSearch">搜索</Button>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex-none bg-white border-b border-gray-200 z-50">
|
||||
<div class="p-4">
|
||||
<input type="search" v-model="searchInput" @keyup="handleKeyup" placeholder="搜索文章"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="articles p-2">
|
||||
<Card v-for="article in store.articles" :key="article.id" class="mb-2 article-card"
|
||||
@click="showArticle(article.id)">
|
||||
<template #title>{{ article.title }}</template>
|
||||
<template #content>{{ article.summary }}</template>
|
||||
</Card>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="p-4">
|
||||
<div v-if="store.articles.length === 0 && !store.loading" class="text-center text-gray-500 py-8">
|
||||
暂无文章
|
||||
</div>
|
||||
|
||||
<div ref="loadingTrigger" v-show="store.hasMore">
|
||||
<ProgressSpinner v-if="store.loading" />
|
||||
<ArticleListItem v-for="article in store.articles" :key="article.id" :article="article"
|
||||
@click="showArticle(article.id)" class="mb-4" />
|
||||
|
||||
<!-- 优化加载触发器位置和显示 -->
|
||||
<div ref="loadingTrigger" class="py-4 text-center" v-show="store.hasMore || store.loading">
|
||||
<div v-if="store.loading"
|
||||
class="animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-blue-600 mx-auto">
|
||||
</div>
|
||||
<div v-else class="h-8">
|
||||
<!-- 空白占位,保持触发器可见 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.hasMore && store.articles.length > 0" class="text-center text-gray-500 py-4">
|
||||
没有更多文章了
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,33 +16,21 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="purchased-articles">
|
||||
<h2>已购买的文章</h2>
|
||||
<div class="articles-list">
|
||||
<div v-if="purchasedArticles.length === 0" class="empty-state">
|
||||
<div class="p-4">
|
||||
<h2 class="text-xl font-medium mb-4">已购买的文章</h2>
|
||||
<div class="space-y-4">
|
||||
<div v-if="purchasedArticles.length === 0" class="py-12 text-center text-gray-500">
|
||||
暂无已购买的文章
|
||||
</div>
|
||||
<Card v-else v-for="article in purchasedArticles" :key="article.id" class="article-card"
|
||||
@click="showArticle(article.id)">
|
||||
<template #title>{{ article.title }}</template>
|
||||
<template #content>{{ article.summary }}</template>
|
||||
</Card>
|
||||
<div v-else v-for="article in purchasedArticles" :key="article.id" @click="showArticle(article.id)"
|
||||
class="bg-white rounded-lg shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer">
|
||||
<h3 class="text-lg font-medium mb-2">{{ article.title }}</h3>
|
||||
<p class="text-gray-600">{{ article.summary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.purchased-articles {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* Remove all styles as they're replaced by Tailwind classes */
|
||||
</style>
|
||||
|
||||
@@ -9,21 +9,21 @@ const userInfo = ref({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-profile p-3">
|
||||
<Card>
|
||||
<template #header>
|
||||
<div class="flex align-items-center gap-3">
|
||||
<Avatar :image="userInfo.avatar" size="large" />
|
||||
<h3>{{ userInfo.name }}</h3>
|
||||
<div class="p-4">
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="flex items-center gap-4 p-4 border-b border-gray-100">
|
||||
<div class="w-16 h-16 rounded-full bg-gray-200 overflow-hidden">
|
||||
<img v-if="userInfo.avatar" :src="userInfo.avatar" alt="头像" class="w-full h-full object-cover">
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="grid">
|
||||
<Button label="我的收藏" class="p-button-text" />
|
||||
<Button label="我的点赞" class="p-button-text" />
|
||||
<Button label="订单列表" class="p-button-text" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<h3 class="text-xl font-medium">{{ userInfo.name }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 p-4">
|
||||
<button v-for="(item, index) in ['我的收藏', '我的点赞', '订单列表']" :key="index"
|
||||
class="py-3 text-center text-gray-700 hover:bg-gray-50 active:bg-gray-100 rounded-lg transition-colors">
|
||||
{{ item }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user