Просмотр исходного кода

feat: post create/edit with Quill editor via route pages

yangyi 2 дней назад
Родитель
Сommit
7e15a03cc8

+ 74 - 0
package-lock.json

@@ -8,6 +8,7 @@
       "name": "ui",
       "version": "0.0.0",
       "dependencies": {
+        "@vueup/vue-quill": "^1.5.3",
         "axios": "^1.15.2",
         "element-plus": "^2.13.6",
         "orval": "7.0",
@@ -2102,6 +2103,19 @@
         }
       }
     },
+    "node_modules/@vueup/vue-quill": {
+      "version": "1.5.3",
+      "resolved": "https://registry.npmjs.org/@vueup/vue-quill/-/vue-quill-1.5.3.tgz",
+      "integrity": "sha512-iklLMFjyChKGmYAUqoIx2t1rigCUSilDo1xJpYk9FLBmXPV3lmsy7fgbYZ11DNbXXGP6TXaQBQhgIy1uHo2rpA==",
+      "license": "MIT",
+      "dependencies": {
+        "quill": "2.0.2 || >=2.0.4 <3",
+        "quill-delta": "^5.1.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.2.41"
+      }
+    },
     "node_modules/@vueuse/core": {
       "version": "12.0.0",
       "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz",
@@ -3012,6 +3026,12 @@
         "node": ">=6"
       }
     },
+    "node_modules/eventemitter3": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+      "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+      "license": "MIT"
+    },
     "node_modules/execa": {
       "version": "5.1.1",
       "resolved": "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz",
@@ -3047,6 +3067,12 @@
       "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
     },
+    "node_modules/fast-diff": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+      "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+      "license": "Apache-2.0"
+    },
     "node_modules/fast-glob": {
       "version": "3.3.3",
       "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -4033,6 +4059,12 @@
         "lodash-es": "*"
       }
     },
+    "node_modules/lodash.clonedeep": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+      "license": "MIT"
+    },
     "node_modules/lodash.get": {
       "version": "4.4.2",
       "resolved": "https://registry.npmmirror.com/lodash.get/-/lodash.get-4.4.2.tgz",
@@ -4044,6 +4076,13 @@
       "resolved": "https://registry.npmmirror.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz",
       "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg=="
     },
+    "node_modules/lodash.isequal": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+      "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+      "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+      "license": "MIT"
+    },
     "node_modules/lodash.omit": {
       "version": "4.18.0",
       "resolved": "https://registry.npmmirror.com/lodash.omit/-/lodash.omit-4.18.0.tgz",
@@ -4534,6 +4573,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/parchment": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
+      "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
+      "license": "BSD-3-Clause"
+    },
     "node_modules/path-browserify": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -4723,6 +4768,35 @@
         }
       ]
     },
+    "node_modules/quill": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.2.tgz",
+      "integrity": "sha512-QfazNrhMakEdRG57IoYFwffUIr04LWJxbS/ZkidRFXYCQt63c1gK6Z7IHUXMx/Vh25WgPBU42oBaNzQ0K1R/xw==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "eventemitter3": "^5.0.1",
+        "lodash-es": "^4.17.21",
+        "parchment": "^3.0.0",
+        "quill-delta": "^5.1.0"
+      },
+      "engines": {
+        "npm": ">=8.2.3"
+      }
+    },
+    "node_modules/quill-delta": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
+      "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-diff": "^1.3.0",
+        "lodash.clonedeep": "^4.5.0",
+        "lodash.isequal": "^4.5.0"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      }
+    },
     "node_modules/readdirp": {
       "version": "3.6.0",
       "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",

+ 1 - 0
package.json

@@ -10,6 +10,7 @@
     "api:gen": "orval"
   },
   "dependencies": {
+    "@vueup/vue-quill": "^1.5.3",
     "axios": "^1.15.2",
     "element-plus": "^2.13.6",
     "orval": "7.0",

+ 1 - 0
src/layout/AdminLayout.vue

@@ -16,6 +16,7 @@ const logoutHandler = () => {
 const menus = computed(() => {
   const userRole = loginUserStore.loginUser.user?.role
   return adminRoutes[0].children?.filter(child => {
+    if (child.meta?.hidden) return false
     const allowedRoles = child.meta?.roles as string[] | undefined
     return !allowedRoles || allowedRoles.includes(userRole ?? '')
   })

+ 13 - 0
src/router/index.ts

@@ -14,6 +14,7 @@ import AdminLayout from "../layout/AdminLayout.vue"
 import UserView from "../view/UserView.vue"
 import SiteSettingsView from "../view/SiteSettingsView.vue"
 import PostManageView from "../view/PostManageView.vue"
+import PostEditorView from "../view/PostEditorView.vue"
 import RealnameReviewView from "../view/RealnameReviewView.vue"
 import WithdrawReviewView from "../view/WithdrawReviewView.vue"
 import {
@@ -104,6 +105,18 @@ export const adminRoutes: RouteRecordRaw[] = [
         component: PostManageView,
         meta: { title: '帖子管理', icon: Document, roles: ['admin', 'expert'] }
       },
+      {
+        path: '/admin/posts/create',
+        name: 'adminPostCreate',
+        component: PostEditorView,
+        meta: { title: '新建帖子', roles: ['admin', 'expert'], hidden: true }
+      },
+      {
+        path: '/admin/posts/:id/edit',
+        name: 'adminPostEdit',
+        component: PostEditorView,
+        meta: { title: '编辑帖子', roles: ['admin', 'expert'], hidden: true }
+      },
       {
         path: '/admin/realname',
         name: 'adminRealname',

+ 3 - 3
src/store/notification.ts

@@ -2,7 +2,7 @@ import { defineStore } from "pinia"
 import { ref } from "vue"
 import type { NotificationItem } from "../type"
 import { getNotificationController } from "../api/notification-controller"
-import type { NotificationVo, ResponseListNotificationVo, ResponseMapStringLong } from "../api/models"
+import type { NotificationVo, ResponseListNotificationVo, ResponseUnreadCountVo } from "../api/models"
 
 function mapNotifVo(v: NotificationVo): NotificationItem {
   return {
@@ -27,9 +27,9 @@ export const useNotificationStore = defineStore('notification', () => {
       if (res.code === 200 && res.data) {
         messages.value = res.data.map(mapNotifVo)
       }
-      const ucRes: ResponseMapStringLong = await getNotificationController().getUnreadCount()
+      const ucRes: ResponseUnreadCountVo = await getNotificationController().getUnreadCount()
       if (ucRes.code === 200 && ucRes.data) {
-        unreadCount.value = (ucRes.data as { count?: number }).count ?? 0
+        unreadCount.value = ucRes.data.count ?? 0
       }
     } catch {
       messages.value = []

+ 2 - 2
src/store/user.ts

@@ -3,7 +3,7 @@ import { computed, reactive, ref } from "vue"
 import type { LoginUser, User, UserProfile } from "../type"
 import { getAuthController } from "../api/auth-controller"
 import { getUserController } from "../api/user-controller"
-import type { ResponseAuthTokenVo, ResponseMapStringObject } from "../api/models"
+import type { ResponseAuthTokenVo, ResponseProfileVo } from "../api/models"
 
 export const useLoginUserStore = defineStore('loginUser', () => {
   const loginUser = reactive<LoginUser>({
@@ -130,7 +130,7 @@ export const useLoginUserStore = defineStore('loginUser', () => {
 
   async function fetchProfile(): Promise<void> {
     try {
-      const res: ResponseMapStringObject = await getUserController().profile()
+      const res: ResponseProfileVo = await getUserController().profile()
       if (res.code === 200 && res.data) {
         const p = res.data as Record<string, unknown>
         fetchUserInfo({

+ 7 - 8
src/store/wallet.ts

@@ -2,7 +2,7 @@ import { defineStore } from "pinia"
 import { ref } from "vue"
 import type { TransactionItem } from "../type"
 import { getWalletController } from "../api/wallet-controller"
-import type { ResponseMapStringObject } from "../api/models"
+import type { ResponseWalletVo, ResponseListWalletTransactionVo, ResponseString, ResponseWithdrawApplyVo } from "../api/models"
 
 export const useWalletStore = defineStore('wallet', () => {
   const balance = ref(0)
@@ -11,9 +11,9 @@ export const useWalletStore = defineStore('wallet', () => {
 
   async function fetchBalance(): Promise<void> {
     try {
-      const res: ResponseMapStringObject = await getWalletController().getBalance()
+      const res: ResponseWalletVo = await getWalletController().getBalance()
       if (res.code === 200 && res.data) {
-        balance.value = (res.data as { balance?: number }).balance ?? 0
+        balance.value = res.data.balance ?? 0
       }
     } catch {
       balance.value = 0
@@ -23,10 +23,9 @@ export const useWalletStore = defineStore('wallet', () => {
   async function fetchTransactions(pageNum = 1, pageSize = 20): Promise<void> {
     loading.value = true
     try {
-      const res: ResponseMapStringObject = await getWalletController().getTransactions({ pageNum, pageSize })
+      const res: ResponseListWalletTransactionVo = await getWalletController().getTransactions({ pageNum, pageSize })
       if (res.code === 200 && res.data) {
-        const rawList = (res.data as { list?: unknown[] }).list ?? []
-        transactions.value = (rawList as Record<string, unknown>[]).map((i) => ({
+        transactions.value = (res.data as Record<string, unknown>[]).map((i) => ({
           id: String(i.id ?? ''),
           type: String(i.type ?? ''),
           amount: Number(i.amount ?? 0),
@@ -46,7 +45,7 @@ export const useWalletStore = defineStore('wallet', () => {
 
   async function recharge(amount: number): Promise<boolean> {
     try {
-      const res: ResponseMapStringObject = await getWalletController().recharge({ amount })
+      const res: ResponseString = await getWalletController().recharge({ amount })
       if (res.code === 200) {
         await fetchBalance()
         return true
@@ -59,7 +58,7 @@ export const useWalletStore = defineStore('wallet', () => {
 
   async function applyWithdraw(amount: number): Promise<boolean> {
     try {
-      const res: ResponseMapStringObject = await getWalletController().applyWithdraw({ amount })
+      const res: ResponseWithdrawApplyVo = await getWalletController().applyWithdraw({ amount })
       return res.code === 200
     } catch {
       return false

+ 149 - 0
src/view/PostEditorView.vue

@@ -0,0 +1,149 @@
+<template>
+  <div class="post-editor">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>{{ isEdit ? '编辑帖子' : '新建帖子' }}</span>
+          <el-button @click="goBack">返回</el-button>
+        </div>
+      </template>
+
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="editor-form">
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="form.title" placeholder="帖子标题(含期号)" />
+        </el-form-item>
+
+        <el-form-item label="价格" prop="price">
+          <el-input-number v-model="form.price" :min="0" :precision="2" :step="10" />
+        </el-form-item>
+
+        <el-form-item label="过期时间" prop="expireTime">
+          <el-date-picker v-model="form.expireTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择过期时间" style="width: 100%" />
+        </el-form-item>
+
+        <el-form-item label="内容简介">
+          <QuillEditor v-model:content="form.contentIntro" content-type="html" :toolbar="toolbar" class="quill-editor" />
+        </el-form-item>
+
+        <el-form-item label="付费内容">
+          <QuillEditor v-model:content="form.contentPaid" content-type="html" :toolbar="toolbar" class="quill-editor" />
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="primary" :loading="submitting" @click="handleSubmit">{{ isEdit ? '保存' : '创建' }}</el-button>
+          <el-button @click="goBack">取消</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import { QuillEditor } from '@vueup/vue-quill'
+import '@vueup/vue-quill/dist/vue-quill.snow.css'
+import { getPostController } from '../api/post-controller'
+
+const route = useRoute()
+const router = useRouter()
+const api = getPostController()
+
+const isEdit = computed(() => !!route.params.id)
+const submitting = ref(false)
+const formRef = ref()
+
+const toolbar = [
+  ['bold', 'italic', 'underline', 'strike'],
+  [{ list: 'ordered' }, { list: 'bullet' }],
+  [{ color: [] }, { background: [] }],
+  ['link', 'image'],
+  ['clean'],
+]
+
+const form = reactive({
+  title: '',
+  contentIntro: '',
+  contentPaid: '',
+  price: 0,
+  expireTime: '',
+})
+
+const rules = {
+  title: [{ required: true, message: '标题不能为空', trigger: 'blur' }],
+  price: [{ required: true, message: '请设置价格', trigger: 'blur' }],
+  expireTime: [{ required: true, message: '请选择过期时间', trigger: 'blur' }],
+}
+
+function goBack() {
+  router.push('/admin/posts')
+}
+
+async function loadPost() {
+  if (!isEdit.value) return
+  try {
+    const res = await api.getPostDetail(Number(route.params.id))
+    if (res.code === 200 && res.data) {
+      form.title = res.data.title ?? ''
+      form.contentIntro = res.data.contentIntro ?? ''
+      form.contentPaid = res.data.contentPaid ?? ''
+      form.price = res.data.price ?? 0
+      form.expireTime = res.data.expireTime ?? ''
+    } else {
+      ElMessage.error('帖子不存在')
+      goBack()
+    }
+  } catch {
+    ElMessage.error('获取帖子失败')
+    goBack()
+  }
+}
+
+async function handleSubmit() {
+  if (submitting.value) return
+  const valid = await formRef.value?.validate().catch(() => false)
+  if (!valid) return
+
+  submitting.value = true
+  const dto = { ...form }
+  try {
+    if (isEdit.value) {
+      await api.updatePost(Number(route.params.id), dto)
+      ElMessage.success('保存成功')
+    } else {
+      await api.createPost(dto)
+      ElMessage.success('创建成功')
+    }
+    goBack()
+  } catch {
+    ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
+  } finally {
+    submitting.value = false
+  }
+}
+
+onMounted(() => loadPost())
+</script>
+
+<style scoped>
+.post-editor {
+  padding: 16px;
+}
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 16px;
+  font-weight: 600;
+}
+.editor-form {
+  max-width: 800px;
+}
+.quill-editor {
+  --qt-height: 300px;
+}
+.quill-editor :deep(.ql-editor) {
+  min-height: 250px;
+}
+</style>

+ 15 - 2
src/view/PostManageView.vue

@@ -4,6 +4,7 @@
       <template #header>
         <div class="card-header">
           <span>帖子管理</span>
+          <el-button type="primary" @click="router.push('/admin/posts/create')">新增帖子</el-button>
         </div>
       </template>
 
@@ -46,8 +47,9 @@
         </el-table-column>
         <el-table-column prop="publishTime" label="发布时间" width="160" />
         <el-table-column prop="expireTime" label="过期时间" width="160" />
-        <el-table-column label="操作" width="200" fixed="right">
+        <el-table-column label="操作" width="280" fixed="right">
           <template #default="{ row }">
+            <el-button v-if="canEdit(row)" size="small" @click="router.push('/admin/posts/' + row.id + '/edit')">编辑</el-button>
             <el-button size="small" @click="editHitStatus(row)">设命中</el-button>
             <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
           </template>
@@ -80,11 +82,22 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { getPostController } from '../api/post-controller'
+import { useLoginUserStore } from '../store'
 import type { PostVo, ListPostsParams } from '../api/models'
 
+const router = useRouter()
+const userStore = useLoginUserStore()
+const currentRole = computed(() => userStore.loginUser.user?.role)
+const currentUserId = computed(() => userStore.loginUser.user?.id)
+
+function canEdit(row: PostVo) {
+  return currentRole.value === 'admin' || (currentRole.value === 'expert' && row.expertId === currentUserId.value)
+}
+
 const api = getPostController()
 const tableData = ref<(PostVo & { _viewCount?: number })[]>([])
 const loading = ref(false)