Browse Source

feat: 新增文档/下载中心并统一页面加载骨架屏

落地公开文档与下载中心及管理端 CRUD,支持 slug 路由与免登录访问;在多页用 Arco 骨架屏替换 a-spin,并修复赠送记录筛选表单布局异常。

Co-authored-by: Cursor <cursoragent@cursor.com>
Pchen. 1 week ago
parent
commit
0472e8c0e1
41 changed files with 2391 additions and 404 deletions
  1. 1 0
      public/download.html
  2. 56 0
      src/api/article.js
  3. 23 0
      src/api/download.js
  4. 33 4
      src/components/AIChat/index.vue
  5. 177 9
      src/components/Header/index.vue
  6. 17 47
      src/components/LepaoAccountCard/accountDetailCard.vue
  7. 138 13
      src/components/Menu/index.vue
  8. 15 6
      src/components/Navbar/index.vue
  9. 12 6
      src/components/layout/AppPageShell.vue
  10. 121 0
      src/components/skeleton/StoreDetailSkeleton.vue
  11. 60 0
      src/components/skeleton/StoreGridSkeleton.vue
  12. 15 0
      src/components/skeleton/StoreRecordSkeleton.vue
  13. 60 0
      src/components/skeleton/StoreStackSkeleton.vue
  14. 4 3
      src/components/store/GoodsEmoji.vue
  15. 161 46
      src/pages/Main/Main.vue
  16. 10 7
      src/pages/Main/theme.less
  17. 419 0
      src/pages/admin/article/index.vue
  18. 144 0
      src/pages/admin/articleCategory/index.vue
  19. 204 0
      src/pages/admin/download/index.vue
  20. 5 2
      src/pages/admin/goods/couponEdit.vue
  21. 8 4
      src/pages/admin/goods/orderDetail.vue
  22. 9 2
      src/pages/admin/lepaoProxy/index.vue
  23. 11 11
      src/pages/admin/lepaoRecords/recordDetail.vue
  24. 5 2
      src/pages/admin/user/userList.vue
  25. 150 0
      src/pages/doc/detail.vue
  26. 220 0
      src/pages/doc/index.vue
  27. 49 73
      src/pages/download/index.vue
  28. 8 54
      src/pages/lepao/accountList/index.vue
  29. 11 11
      src/pages/lepao/lepaoRecords/recordDetail.vue
  30. 15 10
      src/pages/power/accountList.vue
  31. 7 3
      src/pages/service/orderDetail.vue
  32. 24 20
      src/pages/service/orderList.vue
  33. 12 8
      src/pages/store/goodsDetail/index.vue
  34. 9 5
      src/pages/store/goodsList/index.vue
  35. 7 3
      src/pages/store/orders/orderDetail/index.vue
  36. 11 7
      src/pages/store/orders/orderList/index.vue
  37. 8 40
      src/pages/store/sendCountRecords/index.vue
  38. 101 8
      src/router/index.js
  39. 18 0
      src/utils/permission.js
  40. 27 0
      src/utils/slugify.js
  41. 6 0
      src/utils/storeFormat.js

+ 1 - 0
public/download.html

@@ -3,6 +3,7 @@
 
 <head>
   <meta charset="UTF-8">
+  <meta http-equiv="refresh" content="0;url=/#/download">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>RunForge应用下载中心</title>
   <link rel="icon" type="image/svg+xml" href="//xxoo365.top/logo.svg" />

+ 56 - 0
src/api/article.js

@@ -0,0 +1,56 @@
+import request from '../utils/request'
+
+const api = {
+  ArticleCategories: '/Article/Categories',
+  ArticleList: '/Article/List',
+  Article: '/Article',
+  AdminArticleList: '/Admin/Article/List',
+  AdminArticle: '/Admin/Article',
+  AdminArticlePublic: '/Admin/Article/Public',
+  AdminCategoryList: '/Admin/Article/Category/List',
+  AdminCategory: '/Admin/Article/Category'
+}
+
+export function getArticleCategories() {
+  return request({ url: api.ArticleCategories, method: 'get' })
+}
+
+export function getArticleList(params) {
+  return request({ url: api.ArticleList, method: 'get', params })
+}
+
+export function getArticle(params) {
+  return request({ url: api.Article, method: 'get', params })
+}
+
+export function getAdminArticleList(params) {
+  return request({ url: api.AdminArticleList, method: 'get', params })
+}
+
+export function getAdminArticle(params) {
+  return request({ url: api.AdminArticle, method: 'get', params })
+}
+
+export function saveAdminArticle(data) {
+  return request({ url: api.AdminArticle, method: 'post', data })
+}
+
+export function deleteAdminArticle(data) {
+  return request({ url: api.AdminArticle, method: 'delete', data })
+}
+
+export function toggleAdminArticlePublic(data) {
+  return request({ url: api.AdminArticlePublic, method: 'post', data })
+}
+
+export function getAdminCategoryList(params) {
+  return request({ url: api.AdminCategoryList, method: 'get', params })
+}
+
+export function saveAdminCategory(data) {
+  return request({ url: api.AdminCategory, method: 'post', data })
+}
+
+export function deleteAdminCategory(data) {
+  return request({ url: api.AdminCategory, method: 'delete', data })
+}

+ 23 - 0
src/api/download.js

@@ -0,0 +1,23 @@
+import request from '../utils/request'
+
+const api = {
+  DownloadCenter: '/Public/GetDownloadCenter',
+  AdminDownloadList: '/Admin/Download/List',
+  AdminDownload: '/Admin/Download'
+}
+
+export function getDownloadCenter() {
+  return request({ url: api.DownloadCenter, method: 'get' })
+}
+
+export function getAdminDownloadList(params) {
+  return request({ url: api.AdminDownloadList, method: 'get', params })
+}
+
+export function saveAdminDownload(data) {
+  return request({ url: api.AdminDownload, method: 'post', data })
+}
+
+export function deleteAdminDownload(data) {
+  return request({ url: api.AdminDownload, method: 'delete', data })
+}

+ 33 - 4
src/components/AIChat/index.vue

@@ -9,8 +9,15 @@
         </template>
         <div class="container">
             <div class="messages" ref="messagesContainer">
-                <a-spin :loading="messagesLoading">
-                    <div v-for="(item, index) in messages">
+                <a-skeleton animation :loading="messagesLoading" class="messages-skeleton">
+                    <div class="messages-skeleton__list">
+                        <div v-for="n in 3" :key="n" class="messages-skeleton__item" :class="{ 'messages-skeleton__item--right': n === 2 }">
+                            <a-skeleton-shape shape="circle" />
+                            <a-skeleton-line :widths="['180px', '220px']" :rows="2" />
+                        </div>
+                    </div>
+                    <template #content>
+                        <div v-for="(item, index) in messages" :key="index">
                         <div class="time"
                             v-if="!messages[index - 1] || (messages[index].time - messages[index - 1].time) > 300000">{{
                                 stramptoTime(item.time) }}</div>
@@ -30,8 +37,9 @@
                                 </template>
                             </a-dropdown>
                         </div>
-                    </div>
-                </a-spin>
+                        </div>
+                    </template>
+                </a-skeleton>
             </div>
 
             <div class="inputBox">
@@ -262,6 +270,27 @@ const deleteMessages = () => {
         display: flex;
         flex-direction: column;
 
+        .messages-skeleton {
+            width: 100%;
+
+            &__list {
+                display: flex;
+                flex-direction: column;
+                gap: 16px;
+                padding: 8px 0;
+            }
+
+            &__item {
+                display: flex;
+                align-items: flex-start;
+                gap: 10px;
+
+                &--right {
+                    flex-direction: row-reverse;
+                }
+            }
+        }
+
         .time {
             color: #888;
             font-size: 0.9em;

+ 177 - 9
src/components/Header/index.vue

@@ -5,23 +5,45 @@
                 <img alt="RunForge" src="/logo.svg" height="40">
                 <span class="title">RunForge</span>
             </div>
-            <div class="nav-actions">
+            <div class="nav-actions" :style="{ color: props.color }">
+                <div class="nav-links">
+                    <a-button
+                        type="text"
+                        class="nav-btn nav-link"
+                        aria-label="文档中心"
+                        @click="$router.push('/doc')"
+                    >
+                        <icon-book class="nav-btn-icon" />
+                        <span class="nav-btn-label">文档中心</span>
+                    </a-button>
+                    <a-button
+                        type="text"
+                        class="nav-btn nav-link"
+                        aria-label="下载中心"
+                        @click="$router.push('/download')"
+                    >
+                        <icon-download class="nav-btn-icon" />
+                        <span class="nav-btn-label">下载中心</span>
+                    </a-button>
+                </div>
                 <a-button
                     v-if="!user"
                     type="text"
-                    class="nav-btn"
-                    :style="{ color: props.color }"
+                    class="nav-btn nav-auth"
+                    aria-label="用户登录"
                     @click="$router.push('/login')"
                 >
-                    用户登录
+                    <icon-user class="nav-btn-icon" />
+                    <span class="nav-btn-label nav-btn-label--full">用户登录</span>
+                    <span class="nav-btn-label nav-btn-label--short">登录</span>
                 </a-button>
                 <a-dropdown v-else trigger="click" position="br">
                     <button type="button" class="user-trigger" :style="{ color: props.color }">
-                        <a-avatar :size="30">
+                        <a-avatar class="user-avatar" :size="30">
                             <img alt="avatar" :src="user.avatar" />
                         </a-avatar>
                         <span class="username">{{ user.username }}</span>
-                        <icon-down />
+                        <icon-down class="user-trigger-caret" />
                     </button>
                     <template #content>
                         <a-doption @click="$router.push('/user')">
@@ -76,6 +98,9 @@ getuser()
 <style scoped lang="less">
 @import '@/styles/store-theme.less';
 
+@header-mobile-nav-size: 36px;
+@header-mobile-icon-size: 20px;
+
 .site-header {
     position: absolute;
     top: 0;
@@ -115,25 +140,99 @@ getuser()
 }
 
 .nav-actions {
-    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-shrink: 1;
+    min-width: 0;
     margin-left: auto;
+
+    :deep(.arco-btn.nav-btn) {
+        height: 38px;
+        padding: 0 12px;
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        gap: 6px;
+        line-height: 1;
+        color: inherit;
+    }
+
+    :deep(.arco-dropdown) {
+        display: inline-flex;
+        align-items: center;
+        flex-shrink: 0;
+    }
+}
+
+.nav-links {
+    display: flex;
+    align-items: center;
+    gap: 4px 12px;
+}
+
+.nav-btn-icon {
+    display: none;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+    font-size: 16px;
+    line-height: 1;
+}
+
+.nav-btn-label {
+    white-space: nowrap;
 }
 
 .nav-btn {
     font-size: 15px;
 }
 
+.mobile-nav-icon-btn() {
+    width: @header-mobile-nav-size;
+    min-width: @header-mobile-nav-size;
+    height: @header-mobile-nav-size;
+    padding: 0;
+    border-radius: 50%;
+    background: fade(#fff, 58%);
+    border: 1px solid fade(#1b3022, 12%);
+    backdrop-filter: blur(10px);
+    box-shadow: 0 2px 8px fade(#1b3022, 5%);
+}
+
+.mobile-nav-icon-normalize() {
+    width: @header-mobile-icon-size;
+    height: @header-mobile-icon-size;
+    font-size: @header-mobile-icon-size;
+    line-height: 1;
+    margin: 0;
+
+    svg {
+        width: @header-mobile-icon-size;
+        height: @header-mobile-icon-size;
+    }
+}
+
+.nav-auth .nav-btn-label--short {
+    display: none;
+}
+
 .user-trigger {
     display: inline-flex;
     align-items: center;
     gap: 8px;
-    padding: 4px 8px;
+    max-width: min(42vw, 200px);
+    height: 38px;
+    padding: 0 8px;
     border: none;
     background: transparent;
     cursor: pointer;
     font-size: 15px;
+    line-height: 1;
     border-radius: 8px;
     transition: background-color 0.2s;
+    box-sizing: border-box;
+    flex-shrink: 0;
 }
 
 .user-trigger:hover {
@@ -141,7 +240,7 @@ getuser()
 }
 
 .username {
-    max-width: 160px;
+    min-width: 0;
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
@@ -152,5 +251,74 @@ getuser()
         font-size: 1.25em;
         margin: 4px 6px;
     }
+
+    .nav-actions {
+        gap: 8px;
+        flex-shrink: 0;
+    }
+
+    .nav-links {
+        gap: 8px;
+        padding: 0;
+        background: none;
+        border: none;
+        box-shadow: none;
+        backdrop-filter: none;
+    }
+
+    .nav-btn-label {
+        display: none !important;
+    }
+
+    .nav-btn-icon {
+        display: inline-flex;
+        .mobile-nav-icon-normalize();
+    }
+
+    :deep(.arco-btn.nav-btn.nav-link),
+    :deep(.arco-btn.nav-btn.nav-auth) {
+        .mobile-nav-icon-btn();
+    }
+
+    :deep(.arco-btn.nav-btn .arco-icon) {
+        .mobile-nav-icon-normalize();
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+    }
+
+    :deep(.arco-btn.nav-btn:hover) {
+        background: fade(#fff, 72%);
+    }
+
+    .username {
+        display: none;
+    }
+
+    .user-trigger-caret {
+        display: none;
+    }
+
+    .user-trigger {
+        .mobile-nav-icon-btn();
+        max-width: none;
+        justify-content: center;
+    }
+
+    .user-trigger:hover {
+        background: fade(#fff, 72%);
+    }
+
+    :deep(.user-avatar) {
+        width: @header-mobile-icon-size !important;
+        height: @header-mobile-icon-size !important;
+        line-height: @header-mobile-icon-size !important;
+    }
+}
+
+@media (max-width: @app-breakpoint-sm) {
+    .logo .title {
+        display: none;
+    }
 }
 </style>

+ 17 - 47
src/components/LepaoAccountCard/accountDetailCard.vue

@@ -1,22 +1,11 @@
 <template>
-  <a-modal
-    v-model:visible="visible"
-    :title="modalTitle"
-    :width="980"
-    :footer="false"
-    unmount-on-close
-    esc-to-close
-    draggable
-    class="account-detail-modal"
-  >
+  <a-modal v-model:visible="visible" :title="modalTitle" :width="980" :footer="false" unmount-on-close esc-to-close
+    draggable class="account-detail-modal">
     <a-spin :loading="loading" style="width: 100%">
       <template v-if="account">
         <div class="account-header">
           <a-avatar :size="64">
-            <img
-              :alt="account.name ?? ''"
-              :src="account.user_avatar || defaultAvatar"
-            />
+            <img :alt="account.name ?? ''" :src="account.user_avatar || defaultAvatar" />
           </a-avatar>
           <div class="account-header-main">
             <div class="account-name">{{ account.name || '未同步姓名' }}</div>
@@ -46,7 +35,8 @@
           <a-descriptions-item label="自动时段">{{ autoTimeLabel }}</a-descriptions-item>
           <a-descriptions-item label="学期目标">{{ termProgressLabel }}</a-descriptions-item>
           <a-descriptions-item label="添加时间">{{ formatTime(account.create_time) }}</a-descriptions-item>
-          <a-descriptions-item label="上次更新">{{ account.update_time ? formatTime(account.update_time) : '待登录' }}</a-descriptions-item>
+          <a-descriptions-item label="上次更新">{{ account.update_time ? formatTime(account.update_time) : '待登录'
+            }}</a-descriptions-item>
           <a-descriptions-item v-if="admin" label="UA" :span="3">{{ account.userAgent || '-' }}</a-descriptions-item>
           <a-descriptions-item label="备注" :span="3">{{ account.notes || '-' }}</a-descriptions-item>
         </a-descriptions>
@@ -63,14 +53,8 @@
         </div>
         <a-tabs v-model:active-key="recordTab" @change="handleRecordTabChange">
           <a-tab-pane key="platform" title="平台记录">
-            <a-table
-              :data="records"
-              :pagination="{ pageSize: 8, showTotal: true }"
-              :scroll="{ x: 900, y: 280 }"
-              size="small"
-              row-key="id"
-              :bordered="false"
-            >
+            <a-table :data="records" :pagination="{ pageSize: 8, showTotal: true }" :scroll="{ x: 900, y: 280 }"
+              size="small" row-key="id" :bordered="false">
               <template #columns>
                 <a-table-column v-if="admin" title="所属用户" :width="120">
                   <template #cell="{ record }">{{ record.username || '-' }}</template>
@@ -78,10 +62,7 @@
                 <a-table-column title="状态" :width="200">
                   <template #cell="{ record }">
                     <div class="state">
-                      <div
-                        class="circle"
-                        :class="record.result?.record_failed_reason === '自动确认有效' ? 'one' : 'else'"
-                      />
+                      <div class="circle" :class="record.result?.record_failed_reason === '自动确认有效' ? 'one' : 'else'" />
                       {{ record.result?.record_failed_reason }}
                     </div>
                   </template>
@@ -112,34 +93,23 @@
             </a-table>
           </a-tab-pane>
           <a-tab-pane key="official" title="官方记录" :disabled="!canViewOfficialRecords">
-            <a-alert
-              v-if="!canViewOfficialRecords"
-              type="warning"
-              class="official-record-alert"
-              content="仅状态为正常的账号可查看官方记录,请使用登录器更新账号后再试。"
-            />
-            <a-table
-              :data="officialRecords"
-              :loading="officialLoading"
-              :pagination="officialPaginationConfig"
-              :scroll="{ x: 900, y: 280 }"
-              size="small"
-              row-key="id"
-              :bordered="false"
-              @page-change="handleOfficialPageChange"
-            >
+            <a-alert v-if="!canViewOfficialRecords" type="warning" class="official-record-alert"
+              content="仅状态为正常的账号可查看官方记录,请使用登录器更新账号后再试。" />
+            <a-table :data="officialRecords" :loading="officialLoading" :pagination="officialPaginationConfig"
+              :scroll="{ x: 900, y: 280 }" size="small" row-key="id" :bordered="false"
+              @page-change="handleOfficialPageChange">
               <template #columns>
-                <a-table-column title="状态" :width="150">
+                <a-table-column title="状态" :width="70">
                   <template #cell="{ record }">
                     <a-tag :color="String(record.record_status) === '1' ? 'green' : 'orangered'">
                       {{ officialRecordStatus(record) }}
                     </a-tag>
                   </template>
-                </a-table-column>
-                <a-table-column title="跑区" :width="210">
+                </a-table-column> 
+                <a-table-column title="跑区" :width="160">
                   <template #cell="{ record }">{{ record.title || '-' }}</template>
                 </a-table-column>
-                <a-table-column title="类型" :width="120">
+                <a-table-column title="类型" :width="100">
                   <template #cell="{ record }">{{ record.tag || record.point_type_str || '-' }}</template>
                 </a-table-column>
                 <a-table-column title="配速" :width="80">

+ 138 - 13
src/components/Menu/index.vue

@@ -1,81 +1,206 @@
 <template>
+
     <a-menu v-model:selected-keys="selectedKey"
+
         :default-open-keys="selectedKey">
+
         <template v-for="menu in menuData" :key="menu.key">
+
             <a-sub-menu v-if="menu.children" :key="menu.key">
+
                 <template #icon>
+
                     <component :is="menu.icon"></component>
+
                 </template>
+
                 <template #title>{{ menu.label }}</template>
+
                 <a-menu-item v-for="child in menu.children" :key="child.key" :disabled="child.disabled"
+
                     @click="$router.push(child.key)">
+
                     {{ child.label }}
+
                 </a-menu-item>
+
             </a-sub-menu>
 
+
+
             <a-menu-item v-else :key="menu.key" :disabled="menu.disabled" @click="$router.push(menu.key)">
+
                 <template #icon>
+
                     <component :is="menu.icon"></component>
+
                 </template>
+
                 {{ menu.label }}
+
             </a-menu-item>
+
         </template>
+
     </a-menu>
+
 </template>
 
+
+
 <script setup>
-import { onMounted, ref } from 'vue'
+
+import { onMounted, ref, watch } from 'vue'
+
 import { routes } from '@/router'
+
 import { useUserStore } from '@/store/modules/user'
+
 import { useRouteListener } from '@/utils/route-listener'
+
 import { isElectron } from '../../utils/electron'
-import { canAccessRoute } from '@/utils/permission'
+
+import { canShowInMenu } from '@/utils/permission'
+
+
 
 const userStore = useUserStore()
+
 const user = ref({})
+
 const electron = ref(false)
+
 const menuData = ref([])
 
+
+
 const { selectedKey } = useRouteListener()
 
-const hasPermission = (route) => {
-    return canAccessRoute(route, user.value)
-}
+
 
 const checkEnv = (route) => {
+
     if (!route.meta) return true
+
     if (route.meta.onlyWeb) return !electron.value
+
     if (route.meta.onlyElectron) return !!electron.value
+
     return true
+
 }
 
-const generateMenu = (routes, parentPath = '') => {
-    return routes
-        .filter((route) => hasPermission(route) && !(route.meta && route.meta.hideInMenu) && checkEnv(route))
+
+
+const joinRoutePath = (parentPath, routePath) => {
+
+    const base = parentPath.endsWith('/') ? parentPath.slice(0, -1) : parentPath
+
+    const segment = routePath.startsWith('/') ? routePath : `/${routePath}`
+
+    return `${base}${segment}`.replace(/\/+/g, '/')
+
+}
+
+
+
+const generateMenu = (routeList, parentPath = '') => {
+
+    return routeList
+
+        .filter((route) => canShowInMenu(route, user.value) && checkEnv(route))
+
         .map((route) => {
-            const fullPath = parentPath + route.path; // 确保子路由路径完整
+
+            const fullPath = joinRoutePath(parentPath, route.path)
+
             const menu = {
+
                 key: fullPath,
+
                 label: route.meta?.title || route.name,
+
                 icon: route.meta?.icon || '',
+
                 disabled: route.meta?.disabled || false
+
             }
 
+
+
             if (route.children && route.children.length > 0) {
-                menu.children = generateMenu(route.children, fullPath + '/')
+
+                const childPrefix = fullPath.endsWith('/') ? fullPath : `${fullPath}/`
+
+                menu.children = generateMenu(route.children, childPrefix)
+
+                if (menu.children.length === 0) return null
+
+                if (route.meta?.flatMenu && menu.children.length === 1) {
+
+                    const only = menu.children[0]
+
+                    return {
+
+                        ...only,
+
+                        icon: menu.icon || only.icon,
+
+                        label: menu.label || only.label
+
+                    }
+
+                }
+
             }
 
+
+
             return menu
+
         })
+
+        .filter(Boolean)
+
 }
 
-onMounted(async () => {
+
+
+const refreshMenu = async () => {
+
     user.value = await userStore.getInfo()
+
     if ((!user.value.permissionCodes || user.value.permissionCodes.length === 0) && userStore.refreshPermissions) {
+
         await userStore.refreshPermissions()
+
         user.value = userStore.$state
+
     }
+
     electron.value = isElectron()
+
     menuData.value = generateMenu(routes)
-})
-</script>
+
+}
+
+
+
+onMounted(refreshMenu)
+
+
+
+watch(
+
+    () => [userStore.uuid, userStore.session],
+
+    () => {
+
+        refreshMenu()
+
+    }
+
+)
+
+</script>
+

+ 15 - 6
src/components/Navbar/index.vue

@@ -32,7 +32,7 @@
 
       <li v-if="!isElectron()" class="desktop-shortcut">
         <a-tooltip content="下载专区">
-          <a-button class="nav-btn" type="outline" :shape="'circle'" @click="goDownload()">
+          <a-button class="nav-btn" type="outline" :shape="'circle'" @click="$router.push('/download')">
             <template #icon>
               <icon-download />
             </template>
@@ -40,6 +40,16 @@
         </a-tooltip>
       </li>
 
+      <li v-if="!isElectron()" class="desktop-shortcut">
+        <a-tooltip content="文档中心">
+          <a-button class="nav-btn" type="outline" :shape="'circle'" @click="$router.push('/doc')">
+            <template #icon>
+              <icon-book />
+            </template>
+          </a-button>
+        </a-tooltip>
+      </li>
+
       <li v-if="!isElectron()" class="mobile-shortcut">
         <a-dropdown trigger="click" position="br">
           <a-button class="nav-btn" type="outline" :shape="'circle'">
@@ -54,9 +64,12 @@
             <a-doption @click="$router.push('/service/createOrder')">
               <icon-customer-service /> 售后服务
             </a-doption>
-            <a-doption @click="goDownload()">
+            <a-doption @click="$router.push('/download')">
               <icon-download /> 下载专区
             </a-doption>
+            <a-doption @click="$router.push('/doc')">
+              <icon-book /> 文档中心
+            </a-doption>
           </template>
         </a-dropdown>
       </li>
@@ -122,10 +135,6 @@ const avatar = computed(() => {
   return userStore.avatar;
 })
 
-const goDownload = () => {
-  window.open('https://download.xxoo365.top')
-}
-
 const handleLogout = () => {
   Modal.confirm({
     title: '退出登录',

+ 12 - 6
src/components/layout/AppPageShell.vue

@@ -14,9 +14,12 @@
         </slot>
       </header>
       <a-alert v-if="notice" type="info" closable class="app-notice">{{ notice }}</a-alert>
-      <a-spin :loading="loading" class="store-spin">
-        <slot />
-      </a-spin>
+      <a-skeleton animation :loading="loading" class="store-spin">
+        <a-skeleton-line :rows="4" />
+        <template #content>
+          <slot />
+        </template>
+      </a-skeleton>
     </div>
     <template v-else>
       <Breadcrumb v-if="showBreadcrumb" />
@@ -32,9 +35,12 @@
         </slot>
       </header>
       <a-alert v-if="notice" type="info" closable class="app-notice">{{ notice }}</a-alert>
-      <a-spin :loading="loading" class="store-spin">
-        <slot />
-      </a-spin>
+      <a-skeleton animation :loading="loading" class="store-spin">
+        <a-skeleton-line :rows="4" />
+        <template #content>
+          <slot />
+        </template>
+      </a-skeleton>
     </template>
   </div>
 </template>

+ 121 - 0
src/components/skeleton/StoreDetailSkeleton.vue

@@ -0,0 +1,121 @@
+<template>
+  <div class="store-detail-skeleton" :class="`store-detail-skeleton--${variant}`">
+    <template v-if="variant === 'goods'">
+      <div class="store-detail-skeleton__layout">
+        <aside class="store-detail-skeleton__aside">
+          <a-skeleton-shape size="large" />
+          <a-skeleton-line :widths="['85%']" :rows="1" />
+          <a-skeleton-line :widths="['100%', '90%']" :rows="2" />
+          <a-skeleton-line :widths="['50%']" :rows="1" />
+          <a-skeleton-line :widths="['100%']" :rows="1" />
+        </aside>
+        <main class="store-detail-skeleton__main">
+          <a-skeleton-line :widths="['120px']" :rows="1" />
+          <a-skeleton-line :rows="8" />
+        </main>
+      </div>
+    </template>
+
+    <template v-else-if="variant === 'order'">
+      <div class="store-detail-skeleton__card">
+        <a-skeleton-line :widths="['100%']" :rows="1" />
+        <a-skeleton-line :widths="['60%']" :rows="1" />
+      </div>
+      <div class="store-detail-skeleton__card">
+        <a-skeleton-line :widths="['100px']" :rows="1" />
+        <a-skeleton-line :rows="6" />
+      </div>
+      <div class="store-detail-skeleton__card">
+        <a-skeleton-line :widths="['100px']" :rows="1" />
+        <a-skeleton-line :rows="4" />
+      </div>
+    </template>
+
+    <template v-else>
+      <a-space direction="vertical" :style="{ width: '100%' }" size="large">
+        <a-space align="center" :size="16">
+          <a-skeleton-shape size="large" />
+          <a-skeleton-line :widths="['min(320px, 70vw)']" :rows="1" />
+        </a-space>
+        <a-skeleton-line :widths="['72px', '120px']" :rows="1" />
+        <a-skeleton-line :rows="10" />
+      </a-space>
+    </template>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  variant: {
+    type: String,
+    default: 'default',
+    validator: (v) => ['default', 'goods', 'order'].includes(v)
+  }
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.store-detail-skeleton {
+  width: 100%;
+
+  &--goods,
+  &--order,
+  &--default {
+    background: @store-card-bg;
+    border: 1px solid @store-card-border;
+    border-radius: @store-radius;
+    padding: clamp(16px, 4vw, 32px);
+  }
+
+  &--order {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    background: transparent;
+    border: none;
+    padding: 0;
+  }
+
+  &__layout {
+    display: grid;
+    grid-template-columns: minmax(260px, 340px) 1fr;
+    gap: 24px;
+
+    @media (max-width: 900px) {
+      grid-template-columns: 1fr;
+    }
+  }
+
+  &__aside {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    padding: 20px;
+    background: @store-card-bg;
+    border: 1px solid @store-card-border;
+    border-radius: @store-radius;
+  }
+
+  &__main {
+    padding: 20px;
+    background: @store-card-bg;
+    border: 1px solid @store-card-border;
+    border-radius: @store-radius;
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+  }
+
+  &__card {
+    padding: 20px;
+    background: @store-card-bg;
+    border: 1px solid @store-card-border;
+    border-radius: @store-radius;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+}
+</style>

+ 60 - 0
src/components/skeleton/StoreGridSkeleton.vue

@@ -0,0 +1,60 @@
+<template>
+  <div class="store-grid-skeleton" :style="gridStyle">
+    <article v-for="n in count" :key="n" class="store-grid-skeleton__card">
+      <div class="store-grid-skeleton__visual">
+        <a-skeleton-shape size="large" />
+      </div>
+      <div class="store-grid-skeleton__body">
+        <a-skeleton-line :widths="['75%']" :rows="1" />
+        <a-skeleton-line :widths="['100%', '90%']" :rows="2" />
+        <a-skeleton-line :widths="['40%', '35%']" :rows="1" />
+      </div>
+    </article>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+  count: { type: Number, default: 6 },
+  minWidth: { type: String, default: '280px' }
+})
+
+const gridStyle = computed(() => ({
+  gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${props.minWidth}), 1fr))`
+}))
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.store-grid-skeleton {
+  display: grid;
+  gap: 16px;
+  width: 100%;
+
+  &__card {
+    background: @store-card-bg;
+    border: 1px solid @store-card-border;
+    border-radius: @store-radius;
+    overflow: hidden;
+  }
+
+  &__visual {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 120px;
+    background: fade(@store-accent, 8%);
+    border-bottom: 1px solid @store-card-border;
+  }
+
+  &__body {
+    padding: 16px;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+  }
+}
+</style>

+ 15 - 0
src/components/skeleton/StoreRecordSkeleton.vue

@@ -0,0 +1,15 @@
+<template>
+  <a-space direction="vertical" :style="{ width: '100%' }" size="large">
+    <a-skeleton-line :widths="['100%']" :rows="1" />
+    <a-skeleton-line :rows="5" />
+    <a-skeleton-shape size="large" class="store-record-skeleton__map" />
+  </a-space>
+</template>
+
+<style scoped lang="less">
+.store-record-skeleton__map {
+  width: 100%;
+  height: 280px;
+  border-radius: 8px;
+}
+</style>

+ 60 - 0
src/components/skeleton/StoreStackSkeleton.vue

@@ -0,0 +1,60 @@
+<template>
+  <div class="store-stack-skeleton">
+    <div v-for="n in count" :key="n" class="store-stack-skeleton__item">
+      <a-skeleton-shape size="small" />
+      <div class="store-stack-skeleton__content">
+        <a-skeleton-line :widths="headerWidths" :rows="1" />
+        <a-skeleton-line :widths="['100%', '70%']" :rows="2" />
+        <a-skeleton-line v-if="showMeta" :widths="['28%', '32%', '24%']" :rows="1" />
+      </div>
+      <a-skeleton-line v-if="showAction" :widths="['72px']" :rows="1" class="store-stack-skeleton__action" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  count: { type: Number, default: 4 },
+  showMeta: { type: Boolean, default: true },
+  showAction: { type: Boolean, default: true },
+  headerWidths: {
+    type: Array,
+    default: () => ['35%', '55%']
+  }
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.store-stack-skeleton {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  width: 100%;
+
+  &__item {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-start;
+    gap: 16px;
+    padding: 16px 20px;
+    background: @store-card-bg;
+    border: 1px solid @store-card-border;
+    border-radius: @store-radius;
+  }
+
+  &__content {
+    flex: 1 1 200px;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  &__action {
+    flex-shrink: 0;
+    align-self: center;
+  }
+}
+</style>

+ 4 - 3
src/components/store/GoodsEmoji.vue

@@ -6,7 +6,7 @@
 
 <script setup>
 import { computed } from 'vue'
-import { getGoodsEmoji } from '@/utils/storeFormat'
+import { getGoodsEmoji, DEFAULT_GOODS_EMOJI } from '@/utils/storeFormat'
 
 const props = defineProps({
   icon: { type: String, default: '' },
@@ -15,10 +15,11 @@ const props = defineProps({
     default: 'md',
     validator: (v) => ['sm', 'md', 'lg', 'xl'].includes(v)
   },
-  ariaLabel: { type: String, default: '商品图标' }
+  ariaLabel: { type: String, default: '商品图标' },
+  fallback: { type: String, default: '' }
 })
 
-const emoji = computed(() => getGoodsEmoji(props.icon))
+const emoji = computed(() => getGoodsEmoji(props.icon, props.fallback || DEFAULT_GOODS_EMOJI))
 </script>
 
 <style scoped lang="less">

+ 161 - 46
src/pages/Main/Main.vue

@@ -2,9 +2,11 @@
     <div id="section1" class="section hero-section">
         <div class="hero-bg" aria-hidden="true">
             <div class="hero-bg-gradient" />
+            <div class="hero-bg-shimmer" />
             <div class="hero-bg-orb hero-bg-orb--1" />
             <div class="hero-bg-orb hero-bg-orb--2" />
             <div class="hero-bg-orb hero-bg-orb--3" />
+            <div class="hero-bg-orb hero-bg-orb--4" />
         </div>
         <Header color="#1b3022" />
         <div class="hero-inner">
@@ -31,6 +33,7 @@
     <div id="section2" class="section features-wrap">
         <div class="features-bg" aria-hidden="true">
             <div class="features-bg-gradient" />
+            <div class="features-bg-orb" />
         </div>
         <Section2 />
     </div>
@@ -67,8 +70,9 @@ const scrollToSection = (sectionId) => {
 
 .hero-section {
     display: flex;
+    user-select: none;
     flex-direction: column;
-    background: linear-gradient(165deg, #e6f8ec 0%, #d8f3e2 45%, #c5ecd5 100%);
+    background: @home-gradient-pale;
     overflow: hidden;
 }
 
@@ -84,48 +88,82 @@ const scrollToSection = (sectionId) => {
     position: absolute;
     inset: 0;
     background: linear-gradient(
-        145deg,
-        fade(@home-gradient-pale, 85%) 0%,
-        fade(@home-gradient-mint, 75%) 40%,
-        fade(@home-gradient-sage, 68%) 100%
+        125deg,
+        @home-gradient-pale 0%,
+        fade(@home-gradient-mint, 92%) 16%,
+        fade(@home-gradient-teal, 78%) 34%,
+        fade(@home-gradient-green, 82%) 52%,
+        fade(@home-gradient-lime, 55%) 68%,
+        fade(@home-gradient-mint, 88%) 84%,
+        fade(@home-gradient-emerald, 72%) 100%
     );
-    background-size: 180% 180%;
-    animation: heroGradientFlow 14s ease-in-out infinite alternate;
+    background-size: 320% 320%;
+    animation: heroGradientFlow 12s ease-in-out infinite;
+}
+
+.hero-bg-shimmer {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    width: 140%;
+    height: 140%;
+    transform: translate(-50%, -50%);
+    background: conic-gradient(
+        from 0deg at 50% 50%,
+        fade(@home-gradient-teal, 0%) 0deg,
+        fade(@home-gradient-aqua, 28%) 72deg,
+        fade(@home-gradient-lime, 18%) 144deg,
+        fade(@home-gradient-emerald, 24%) 216deg,
+        fade(@home-gradient-mint, 14%) 288deg,
+        fade(@home-gradient-teal, 0%) 360deg
+    );
+    opacity: 0.55;
+    filter: blur(48px);
+    animation: shimmerRotate 18s linear infinite;
 }
 
 .hero-bg-orb {
     position: absolute;
     border-radius: 50%;
-    filter: blur(56px);
-    opacity: 0.42;
+    filter: blur(64px);
+    opacity: 0.55;
     will-change: transform, opacity;
 }
 
 .hero-bg-orb--1 {
-    width: clamp(220px, 34vw, 460px);
-    height: clamp(220px, 34vw, 460px);
-    top: -12%;
-    right: -4%;
-    background: radial-gradient(circle, fade(@home-gradient-deep, 58%) 0%, fade(@home-gradient-green, 24%) 46%, transparent 76%);
-    animation: orbFloatOne 12s ease-in-out infinite;
+    width: clamp(260px, 38vw, 520px);
+    height: clamp(260px, 38vw, 520px);
+    top: -14%;
+    right: -8%;
+    background: radial-gradient(circle, fade(@home-gradient-emerald, 72%) 0%, fade(@home-gradient-green, 38%) 42%, transparent 72%);
+    animation: orbFloatOne 10s ease-in-out infinite;
 }
 
 .hero-bg-orb--2 {
-    width: clamp(220px, 30vw, 380px);
-    height: clamp(220px, 30vw, 380px);
-    bottom: 5%;
-    left: -10%;
-    background: radial-gradient(circle, fade(@home-gradient-teal, 54%) 0%, fade(@home-gradient-sage, 22%) 52%, transparent 76%);
-    animation: orbFloatTwo 15s ease-in-out infinite;
+    width: clamp(240px, 34vw, 420px);
+    height: clamp(240px, 34vw, 420px);
+    bottom: 0%;
+    left: -12%;
+    background: radial-gradient(circle, fade(@home-gradient-aqua, 68%) 0%, fade(@home-gradient-teal, 32%) 48%, transparent 74%);
+    animation: orbFloatTwo 13s ease-in-out infinite;
 }
 
 .hero-bg-orb--3 {
-    width: clamp(180px, 22vw, 300px);
-    height: clamp(180px, 22vw, 300px);
-    top: 34%;
-    left: 30%;
-    background: radial-gradient(circle, fade(#fff, 80%) 0%, fade(@home-gradient-mint, 22%) 45%, transparent 76%);
-    animation: orbFloatThree 11s ease-in-out infinite;
+    width: clamp(200px, 26vw, 340px);
+    height: clamp(200px, 26vw, 340px);
+    top: 28%;
+    left: 26%;
+    background: radial-gradient(circle, fade(#fff, 92%) 0%, fade(@home-gradient-lime, 36%) 38%, transparent 72%);
+    animation: orbFloatThree 9s ease-in-out infinite;
+}
+
+.hero-bg-orb--4 {
+    width: clamp(180px, 24vw, 320px);
+    height: clamp(180px, 24vw, 320px);
+    bottom: 18%;
+    right: 12%;
+    background: radial-gradient(circle, fade(@home-gradient-lime, 58%) 0%, fade(@home-gradient-deep, 28%) 50%, transparent 76%);
+    animation: orbFloatFour 11s ease-in-out infinite;
 }
 
 .hero-inner {
@@ -145,6 +183,7 @@ const scrollToSection = (sectionId) => {
 
 .hero-visual {
     flex: 0 0 auto;
+    user-select: none;
     max-width: min(44vw, 460px);
 }
 
@@ -193,11 +232,27 @@ const scrollToSection = (sectionId) => {
     position: absolute;
     inset: 0;
     background: linear-gradient(
-        160deg,
-        fade(@home-bg-page, 98%) 0%,
-        fade(@home-gradient-mint, 32%) 48%,
-        fade(@home-gradient-teal, 22%) 100%
+        145deg,
+        fade(@home-bg-page, 96%) 0%,
+        fade(@home-gradient-mint, 48%) 35%,
+        fade(@home-gradient-teal, 38%) 65%,
+        fade(@home-gradient-aqua, 28%) 100%
     );
+    background-size: 240% 240%;
+    animation: featuresGradientFlow 16s ease-in-out infinite alternate;
+}
+
+.features-bg-orb {
+    position: absolute;
+    width: clamp(280px, 42vw, 560px);
+    height: clamp(280px, 42vw, 560px);
+    top: -20%;
+    right: -15%;
+    border-radius: 50%;
+    background: radial-gradient(circle, fade(@home-gradient-teal, 42%) 0%, fade(@home-gradient-mint, 18%) 50%, transparent 72%);
+    filter: blur(72px);
+    opacity: 0.5;
+    animation: featuresOrbFloat 14s ease-in-out infinite;
 }
 
 .features-wrap > :not(.features-bg) {
@@ -238,40 +293,74 @@ const scrollToSection = (sectionId) => {
 
 @keyframes heroGradientFlow {
     0% {
-        background-position: 0% 50%;
-        filter: saturate(1);
+        background-position: 0% 0%;
+        filter: saturate(1) hue-rotate(0deg);
     }
-    50% {
-        background-position: 50% 45%;
-        filter: saturate(1.08);
+    33% {
+        background-position: 100% 0%;
+        filter: saturate(1.12) hue-rotate(6deg);
+    }
+    66% {
+        background-position: 100% 100%;
+        filter: saturate(1.18) hue-rotate(-4deg);
+    }
+    100% {
+        background-position: 0% 100%;
+        filter: saturate(1.08) hue-rotate(0deg);
+    }
+}
+
+@keyframes shimmerRotate {
+    from {
+        transform: translate(-50%, -50%) rotate(0deg);
+    }
+    to {
+        transform: translate(-50%, -50%) rotate(360deg);
+    }
+}
+
+@keyframes featuresGradientFlow {
+    0% {
+        background-position: 0% 50%;
     }
     100% {
         background-position: 100% 50%;
-        filter: saturate(1.16);
     }
 }
 
-@keyframes orbFloatOne {
+@keyframes featuresOrbFloat {
     0%,
     100% {
         transform: translate3d(0, 0, 0) scale(1);
-        opacity: 0.46;
+        opacity: 0.42;
     }
     50% {
-        transform: translate3d(-18px, 22px, 0) scale(1.08);
+        transform: translate3d(-28px, 24px, 0) scale(1.12);
         opacity: 0.62;
     }
 }
 
+@keyframes orbFloatOne {
+    0%,
+    100% {
+        transform: translate3d(0, 0, 0) scale(1);
+        opacity: 0.52;
+    }
+    50% {
+        transform: translate3d(-36px, 32px, 0) scale(1.14);
+        opacity: 0.78;
+    }
+}
+
 @keyframes orbFloatTwo {
     0%,
     100% {
         transform: translate3d(0, 0, 0) scale(1);
-        opacity: 0.4;
+        opacity: 0.48;
     }
     50% {
-        transform: translate3d(22px, -16px, 0) scale(1.1);
-        opacity: 0.56;
+        transform: translate3d(40px, -28px, 0) scale(1.16);
+        opacity: 0.72;
     }
 }
 
@@ -279,11 +368,37 @@ const scrollToSection = (sectionId) => {
     0%,
     100% {
         transform: translate3d(0, 0, 0) scale(1);
-        opacity: 0.36;
+        opacity: 0.44;
+    }
+    50% {
+        transform: translate3d(-24px, -22px, 0) scale(1.18);
+        opacity: 0.68;
+    }
+}
+
+@keyframes orbFloatFour {
+    0%,
+    100% {
+        transform: translate3d(0, 0, 0) scale(1);
+        opacity: 0.4;
     }
     50% {
-        transform: translate3d(-12px, -14px, 0) scale(1.14);
-        opacity: 0.5;
+        transform: translate3d(20px, 18px, 0) scale(1.1);
+        opacity: 0.64;
+    }
+}
+
+@media (prefers-reduced-motion: reduce) {
+    .hero-bg-gradient,
+    .hero-bg-shimmer,
+    .hero-bg-orb,
+    .features-bg-gradient,
+    .features-bg-orb {
+        animation: none;
+    }
+
+    .hero-bg-shimmer {
+        opacity: 0.35;
     }
 }
 </style>

+ 10 - 7
src/pages/Main/theme.less

@@ -11,10 +11,13 @@
 @home-radius-card: 16px;
 @home-shadow-card: 0 8px 32px rgba(27, 48, 34, 0.08);
 
-// 动态渐变辅助色(拉大明度/色相差,便于流动动画可见)
-@home-gradient-pale: #f2fcf5;
-@home-gradient-mint: #c8ebd4;
-@home-gradient-green: #8ed4a6;
-@home-gradient-sage: #6bc48a;
-@home-gradient-teal: #7dd4be;
-@home-gradient-deep: #52b878;
+// 动态渐变辅助色(拉大色相/明度差,便于流动动画可见)
+@home-gradient-pale: #f0fdf4;
+@home-gradient-mint: #bbf7d0;
+@home-gradient-green: #6ee7a0;
+@home-gradient-sage: #34d399;
+@home-gradient-teal: #5eead4;
+@home-gradient-aqua: #2dd4bf;
+@home-gradient-lime: #a3e635;
+@home-gradient-deep: #22c55e;
+@home-gradient-emerald: #10b981;

+ 419 - 0
src/pages/admin/article/index.vue

@@ -0,0 +1,419 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="文章管理">
+      <a-space wrap style="margin-bottom: 12px;">
+        <a-button type="primary" @click="openCreate">新增文章</a-button>
+        <a-select v-model="filterType" placeholder="分类" style="width: 140px;" allow-clear @change="onFilterChange">
+          <a-option value="all">全部分类</a-option>
+          <a-option v-for="cat in categories" :key="cat.slug" :value="cat.slug">{{ cat.name }}</a-option>
+        </a-select>
+        <a-select v-model="filterState" style="width: 120px;" @change="onFilterChange">
+          <a-option :value="1">已发布</a-option>
+          <a-option :value="0">草稿</a-option>
+        </a-select>
+      </a-space>
+
+      <a-table
+        :bordered="false"
+        :data="data"
+        :loading="loading"
+        :pagination="{
+          showPageSize: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="封面" :width="70">
+            <template #cell="{ record }">
+              <GoodsEmoji :icon="record.cover" size="sm" :fallback="DEFAULT_ARTICLE_EMOJI" aria-label="文章封面" />
+            </template>
+          </a-table-column>
+          <a-table-column title="标题" data-index="title" :width="200" ellipsis tooltip />
+          <a-table-column title="标识 (slug)" data-index="slug" :width="180" ellipsis tooltip />
+          <a-table-column title="分类" :width="100">
+            <template #cell="{ record }">{{ categoryName(record.type) }}</template>
+          </a-table-column>
+          <a-table-column title="状态" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="record.state === 1 ? 'green' : 'gray'">{{ record.state === 1 ? '已发布' : '草稿' }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="浏览" data-index="views" :width="70" />
+          <a-table-column title="作者" data-index="author" :width="100" />
+          <a-table-column title="时间" :width="160">
+            <template #cell="{ record }">{{ formatTime(record.time) }}</template>
+          </a-table-column>
+          <a-table-column title="操作" :width="220">
+            <template #cell="{ record }">
+              <a-space wrap>
+                <a-button size="mini" @click="openEdit(record)">编辑</a-button>
+                <a-button size="mini" @click="togglePublic(record)">{{ record.state === 1 ? '撤回' : '发布' }}</a-button>
+                <a-button status="danger" size="mini" @click="remove(record)">删除</a-button>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal v-model:visible="editorVisible" :title="form.id ? '编辑文章' : '新增文章'" width="980px" @before-ok="submitEditor">
+      <a-form :model="form" layout="vertical">
+        <a-form-item label="标题" required>
+          <a-input v-model="form.title" allow-clear />
+        </a-form-item>
+        <a-form-item v-if="!form.id" label="文章标识 (slug)" required>
+          <a-input-group>
+            <a-input v-model="form.slug" allow-clear placeholder="小写字母、数字、连字符" />
+            <a-button @click="generateSlug">根据标题生成</a-button>
+          </a-input-group>
+        </a-form-item>
+        <a-form-item v-else label="文章标识 (slug)">
+          <a-input v-model="form.slug" readonly />
+        </a-form-item>
+        <a-form-item label="分类" required>
+          <a-select v-model="form.type" placeholder="选择分类">
+            <a-option v-for="cat in categories" :key="cat.slug" :value="cat.slug">{{ cat.name }}</a-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="封面(Emoji)" required>
+          <div class="emoji-panel">
+            <div class="emoji-panel__preview">
+              <span class="emoji-panel__emoji">{{ displayEmoji }}</span>
+              <span class="emoji-panel__hint">当前封面</span>
+            </div>
+            <div class="emoji-panel__main">
+              <div class="emoji-panel__label">快速选择</div>
+              <div class="emoji-grid">
+                <button
+                  v-for="emoji in presetEmojis"
+                  :key="emoji"
+                  type="button"
+                  class="emoji-grid__item"
+                  :class="{ 'emoji-grid__item--active': form.cover === emoji }"
+                  :title="emoji"
+                  @click="form.cover = emoji"
+                >
+                  {{ emoji }}
+                </button>
+              </div>
+              <div class="emoji-panel__label emoji-panel__label--custom">自定义</div>
+              <a-input
+                v-model="form.cover"
+                class="emoji-custom-input"
+                placeholder="输入或粘贴一个 emoji"
+                :max-length="8"
+                allow-clear
+              />
+            </div>
+          </div>
+        </a-form-item>
+        <a-form-item label="摘要">
+          <a-textarea v-model="form.describe" :auto-size="{ minRows: 2 }" />
+        </a-form-item>
+        <a-form-item label="发布状态">
+          <a-radio-group v-model="form.state">
+            <a-radio :value="1">发布</a-radio>
+            <a-radio :value="0">草稿</a-radio>
+          </a-radio-group>
+        </a-form-item>
+        <a-form-item label="正文(富文本)" required>
+          <WangEditor v-model="form.content" />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, computed, onMounted } from 'vue'
+import { Modal, Notification, Message } from '@arco-design/web-vue'
+import WangEditor from '@/components/Editor/WangEditor.vue'
+import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
+import { slugify, isValidSlug } from '@/utils/slugify'
+import { DEFAULT_ARTICLE_EMOJI, getArticleEmoji } from '@/utils/storeFormat'
+import {
+  getAdminArticleList,
+  getAdminArticle,
+  saveAdminArticle,
+  deleteAdminArticle,
+  toggleAdminArticlePublic,
+  getAdminCategoryList
+} from '@/api/article'
+
+const presetEmojis = ['📄', '📖', '📋', '❓', '📰', '🏃', '💡', '✅', '⚡', '✨']
+
+const loading = ref(false)
+const data = ref([])
+const categories = ref([])
+const pagination = reactive({ total: 0, current: 1, pagesize: 20 })
+const filterType = ref('all')
+const filterState = ref(1)
+
+const editorVisible = ref(false)
+const form = reactive({
+  id: null,
+  slug: '',
+  title: '',
+  cover: '',
+  describe: '',
+  content: '',
+  type: '',
+  state: 1
+})
+
+const displayEmoji = computed(() => getArticleEmoji(form.cover))
+
+const formatTime = (time) => new Date(Number(time)).toLocaleString('zh-CN')
+const categoryName = (slug) => categories.value.find(c => c.slug === slug)?.name || slug
+
+const loadCategories = async () => {
+  const res = await getAdminCategoryList({})
+  if (res?.code === 0) {
+    categories.value = res.data || []
+    if (!form.type && categories.value.length) form.type = categories.value[0].slug
+  }
+}
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const res = await getAdminArticleList({
+      pagesize: pagination.pagesize,
+      current: pagination.current,
+      type: filterType.value || 'all',
+      state: filterState.value
+    })
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '获取文章列表失败', content: res?.msg ?? '请稍后再试' })
+      return
+    }
+    data.value = res.data || []
+    pagination.total = res.pagination?.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const onFilterChange = () => {
+  pagination.current = 1
+  getList()
+}
+
+const generateSlug = () => {
+  if (!form.title) return Message.warning('请先填写标题')
+  form.slug = slugify(form.title)
+}
+
+const openCreate = () => {
+  form.id = null
+  form.slug = ''
+  form.title = ''
+  form.cover = DEFAULT_ARTICLE_EMOJI
+  form.describe = ''
+  form.content = ''
+  form.state = 1
+  form.type = categories.value[0]?.slug || ''
+  editorVisible.value = true
+}
+
+const openEdit = async (record) => {
+  const res = await getAdminArticle({ id: record.id })
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '获取文章失败', content: res?.msg ?? '请稍后再试' })
+    return
+  }
+  const a = res.data
+  form.id = a.id
+  form.slug = a.slug
+  form.title = a.title
+  form.cover = getArticleEmoji(a.cover)
+  form.describe = a.describe || ''
+  form.content = a.content || ''
+  form.type = a.type
+  form.state = a.state
+  editorVisible.value = true
+}
+
+const submitEditor = async () => {
+  if (!form.title || !form.cover || !form.content || !form.type) {
+    Message.error('请填写必填项')
+    return false
+  }
+  if (!form.id) {
+    if (!form.slug || !isValidSlug(form.slug)) {
+      Message.error('请填写有效的文章标识 (slug)')
+      return false
+    }
+  }
+  const payload = {
+    id: form.id,
+    slug: form.slug,
+    title: form.title,
+    cover: getArticleEmoji(form.cover),
+    describe: form.describe,
+    content: form.content,
+    type: form.type,
+    state: form.state
+  }
+  const res = await saveAdminArticle(payload)
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '保存失败', content: res?.msg ?? '请稍后再试' })
+    return false
+  }
+  Message.success('保存成功')
+  getList()
+  return true
+}
+
+const togglePublic = async (record) => {
+  const res = await toggleAdminArticlePublic({ id: record.id })
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '操作失败', content: res?.msg ?? '请稍后再试' })
+    return
+  }
+  Message.success('操作成功')
+  getList()
+}
+
+const remove = (record) => {
+  Modal.confirm({
+    title: '删除文章',
+    content: `确定删除「${record.title}」吗?`,
+    onOk: async () => {
+      const res = await deleteAdminArticle({ id: record.id })
+      if (!res || res.code !== 0) {
+        Notification.error({ title: '删除失败', content: res?.msg ?? '请稍后再试' })
+        return
+      }
+      Message.success('删除成功')
+      getList()
+    }
+  })
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  getList()
+}
+const onPageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  getList()
+}
+
+onMounted(async () => {
+  await loadCategories()
+  getList()
+})
+</script>
+
+<style scoped lang="less">
+.emoji-panel {
+  display: flex;
+  gap: 24px;
+  padding: 20px;
+  background: var(--color-fill-1);
+  border: 1px solid var(--color-border-2);
+  border-radius: 12px;
+  align-items: stretch;
+
+  @media (max-width: 640px) {
+    flex-direction: column;
+  }
+
+  &__preview {
+    flex-shrink: 0;
+    width: 120px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+    background: var(--color-bg-2);
+    border-radius: 12px;
+    border: 1px solid var(--color-border-2);
+  }
+
+  &__emoji {
+    font-size: 3.5rem;
+    line-height: 1;
+  }
+
+  &__hint {
+    font-size: 12px;
+    color: var(--color-text-3);
+  }
+
+  &__main {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  &__label {
+    font-size: 13px;
+    font-weight: 500;
+    color: var(--color-text-2);
+
+    &--custom {
+      margin-top: 4px;
+    }
+  }
+}
+
+.emoji-grid {
+  display: grid;
+  grid-template-columns: repeat(5, 52px);
+  gap: 10px;
+
+  @media (max-width: 480px) {
+    grid-template-columns: repeat(5, 1fr);
+  }
+
+  &__item {
+    width: 52px;
+    height: 52px;
+    padding: 0;
+    font-size: 26px;
+    line-height: 1;
+    border: 2px solid var(--color-border-2);
+    border-radius: 10px;
+    background: var(--color-bg-2);
+    cursor: pointer;
+    transition: border-color 0.2s, background 0.2s, transform 0.15s;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    @media (max-width: 480px) {
+      width: 100%;
+      aspect-ratio: 1;
+      height: auto;
+    }
+
+    &:hover {
+      transform: scale(1.06);
+      border-color: rgb(var(--primary-5));
+    }
+
+    &--active {
+      border-color: rgb(var(--primary-6));
+      background: var(--color-primary-light-1);
+      box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.15);
+    }
+  }
+}
+
+.emoji-custom-input {
+  max-width: 320px;
+  height: 40px;
+}
+</style>

+ 144 - 0
src/pages/admin/articleCategory/index.vue

@@ -0,0 +1,144 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="文档分类">
+      <a-space style="margin-bottom: 12px;">
+        <a-button type="primary" @click="openCreate">新增分类</a-button>
+      </a-space>
+
+      <a-table :bordered="false" :data="data" :loading="loading" :pagination="false">
+        <template #columns>
+          <a-table-column title="名称" data-index="name" :width="140" />
+          <a-table-column title="标识 (slug)" data-index="slug" :width="120" />
+          <a-table-column title="图标" :width="80">
+            <template #cell="{ record }">{{ record.icon || '-' }}</template>
+          </a-table-column>
+          <a-table-column title="排序" data-index="sort_order" :width="80" />
+          <a-table-column title="状态" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="record.is_active ? 'green' : 'gray'">{{ record.is_active ? '启用' : '停用' }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="操作" :width="160">
+            <template #cell="{ record }">
+              <a-space>
+                <a-button size="mini" @click="openEdit(record)">编辑</a-button>
+                <a-button status="danger" size="mini" @click="remove(record)">删除</a-button>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal v-model:visible="editorVisible" :title="form.id ? '编辑分类' : '新增分类'" @before-ok="submitEditor">
+      <a-form :model="form" layout="vertical">
+        <a-form-item label="名称" required>
+          <a-input v-model="form.name" allow-clear />
+        </a-form-item>
+        <a-form-item label="标识 (slug)" required>
+          <a-input v-model="form.slug" :readonly="!!form.id" allow-clear placeholder="如 doc、guide" />
+        </a-form-item>
+        <a-form-item label="图标 (emoji)">
+          <a-input v-model="form.icon" allow-clear placeholder="📖" :max-length="16" />
+        </a-form-item>
+        <a-form-item label="排序">
+          <a-input-number v-model="form.sort_order" mode="button" />
+        </a-form-item>
+        <a-form-item label="启用">
+          <a-switch v-model="form.is_active" :checked-value="1" :unchecked-value="0" />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted } from 'vue'
+import { Modal, Notification, Message } from '@arco-design/web-vue'
+import { getAdminCategoryList, saveAdminCategory, deleteAdminCategory } from '@/api/article'
+import { isValidSlug } from '@/utils/slugify'
+
+const loading = ref(false)
+const data = ref([])
+const editorVisible = ref(false)
+const form = reactive({
+  id: null,
+  name: '',
+  slug: '',
+  icon: '',
+  sort_order: 0,
+  is_active: 1
+})
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const res = await getAdminCategoryList({})
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '获取分类失败', content: res?.msg ?? '请稍后再试' })
+      return
+    }
+    data.value = res.data || []
+  } finally {
+    loading.value = false
+  }
+}
+
+const openCreate = () => {
+  form.id = null
+  form.name = ''
+  form.slug = ''
+  form.icon = ''
+  form.sort_order = 0
+  form.is_active = 1
+  editorVisible.value = true
+}
+
+const openEdit = (record) => {
+  form.id = record.id
+  form.name = record.name
+  form.slug = record.slug
+  form.icon = record.icon || ''
+  form.sort_order = record.sort_order || 0
+  form.is_active = record.is_active ? 1 : 0
+  editorVisible.value = true
+}
+
+const submitEditor = async () => {
+  if (!form.name || !form.slug) {
+    Message.error('请填写名称和标识')
+    return false
+  }
+  if (!isValidSlug(form.slug) || form.slug.length > 32) {
+    Message.error('标识格式无效(3-32位小写字母、数字、连字符)')
+    return false
+  }
+  const res = await saveAdminCategory({ ...form })
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '保存失败', content: res?.msg ?? '请稍后再试' })
+    return false
+  }
+  Message.success('保存成功')
+  getList()
+  return true
+}
+
+const remove = (record) => {
+  Modal.confirm({
+    title: '删除分类',
+    content: `确定删除「${record.name}」吗?`,
+    onOk: async () => {
+      const res = await deleteAdminCategory({ id: record.id })
+      if (!res || res.code !== 0) {
+        Notification.error({ title: '删除失败', content: res?.msg ?? '请稍后再试' })
+        return
+      }
+      Message.success('删除成功')
+      getList()
+    }
+  })
+}
+
+onMounted(getList)
+</script>

+ 204 - 0
src/pages/admin/download/index.vue

@@ -0,0 +1,204 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="下载项管理">
+      <a-space style="margin-bottom: 12px;">
+        <a-button type="primary" @click="openCreate">新增下载项</a-button>
+      </a-space>
+
+      <a-table :bordered="false" :data="data" :loading="loading" :pagination="false">
+        <template #columns>
+          <a-table-column title="标题" data-index="title" :width="200" ellipsis tooltip />
+          <a-table-column title="图标" :width="70">
+            <template #cell="{ record }">{{ record.icon || '📦' }}</template>
+          </a-table-column>
+          <a-table-column title="版本" data-index="version" :width="90" />
+          <a-table-column title="平台" data-index="platform" :width="90" />
+          <a-table-column title="排序" data-index="sort_order" :width="70" />
+          <a-table-column title="状态" :width="90">
+            <template #cell="{ record }">
+              <a-tag :color="record.is_active ? 'green' : 'gray'">{{ record.is_active ? '启用' : '停用' }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="操作" :width="160">
+            <template #cell="{ record }">
+              <a-space>
+                <a-button size="mini" @click="openEdit(record)">编辑</a-button>
+                <a-button status="danger" size="mini" @click="remove(record)">删除</a-button>
+              </a-space>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal v-model:visible="editorVisible" :title="form.id ? '编辑下载项' : '新增下载项'" width="720px" @before-ok="submitEditor">
+      <a-form :model="form" layout="vertical">
+        <a-form-item label="标题" required>
+          <a-input v-model="form.title" allow-clear />
+        </a-form-item>
+        <a-form-item label="描述">
+          <a-textarea v-model="form.description" :auto-size="{ minRows: 2 }" />
+        </a-form-item>
+        <a-form-item label="下载链接" required>
+          <a-input v-model="form.download_url" allow-clear />
+        </a-form-item>
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="版本号">
+              <a-input v-model="form.version" allow-clear placeholder="v1.0.0" />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="图标 (emoji)">
+              <a-input v-model="form.icon" allow-clear :max-length="16" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="按钮文案">
+              <a-input v-model="form.button_text" allow-clear />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="平台">
+              <a-select v-model="form.platform" allow-clear placeholder="选择平台">
+                <a-option value="android">Android</a-option>
+                <a-option value="windows">Windows</a-option>
+                <a-option value="ios">iOS</a-option>
+                <a-option value="mac">macOS</a-option>
+              </a-select>
+            </a-form-item>
+          </a-col>
+        </a-row>
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="提取码">
+              <a-input v-model="form.extract_code" allow-clear />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="排序">
+              <a-input-number v-model="form.sort_order" mode="button" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        <a-form-item label="启用">
+          <a-switch v-model="form.is_active" :checked-value="1" :unchecked-value="0" />
+        </a-form-item>
+        <a-form-item label="更新日志 (HTML)">
+          <a-textarea v-model="form.changelog_html" :auto-size="{ minRows: 3 }" placeholder="可选,支持 HTML" />
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted } from 'vue'
+import { Modal, Notification, Message } from '@arco-design/web-vue'
+import { getAdminDownloadList, saveAdminDownload, deleteAdminDownload } from '@/api/download'
+
+const loading = ref(false)
+const data = ref([])
+const editorVisible = ref(false)
+const form = reactive({
+  id: null,
+  title: '',
+  description: '',
+  download_url: '',
+  version: '',
+  icon: '📦',
+  button_text: '立即下载',
+  button_color: 'primary',
+  platform: '',
+  extract_code: '',
+  changelog_html: '',
+  sort_order: 0,
+  is_active: 1
+})
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const res = await getAdminDownloadList({})
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '获取列表失败', content: res?.msg ?? '请稍后再试' })
+      return
+    }
+    data.value = res.data || []
+  } finally {
+    loading.value = false
+  }
+}
+
+const openCreate = () => {
+  form.id = null
+  form.title = ''
+  form.description = ''
+  form.download_url = ''
+  form.version = ''
+  form.icon = '📦'
+  form.button_text = '立即下载'
+  form.button_color = 'primary'
+  form.platform = ''
+  form.extract_code = ''
+  form.changelog_html = ''
+  form.sort_order = 0
+  form.is_active = 1
+  editorVisible.value = true
+}
+
+const openEdit = (record) => {
+  Object.assign(form, {
+    id: record.id,
+    title: record.title,
+    description: record.description || '',
+    download_url: record.download_url,
+    version: record.version || '',
+    icon: record.icon || '📦',
+    button_text: record.button_text || '立即下载',
+    button_color: record.button_color || 'primary',
+    platform: record.platform || '',
+    extract_code: record.extract_code || '',
+    changelog_html: record.changelog_html || '',
+    sort_order: record.sort_order || 0,
+    is_active: record.is_active ? 1 : 0
+  })
+  editorVisible.value = true
+}
+
+const submitEditor = async () => {
+  if (!form.title || !form.download_url) {
+    Message.error('请填写标题和下载链接')
+    return false
+  }
+  const res = await saveAdminDownload({ ...form })
+  if (!res || res.code !== 0) {
+    Notification.error({ title: '保存失败', content: res?.msg ?? '请稍后再试' })
+    return false
+  }
+  Message.success('保存成功')
+  getList()
+  return true
+}
+
+const remove = (record) => {
+  Modal.confirm({
+    title: '删除下载项',
+    content: `确定删除「${record.title}」吗?`,
+    onOk: async () => {
+      const res = await deleteAdminDownload({ id: record.id })
+      if (!res || res.code !== 0) {
+        Notification.error({ title: '删除失败', content: res?.msg ?? '请稍后再试' })
+        return
+      }
+      Message.success('删除成功')
+      getList()
+    }
+  })
+}
+
+onMounted(getList)
+</script>

+ 5 - 2
src/pages/admin/goods/couponEdit.vue

@@ -3,7 +3,9 @@
     <Breadcrumb />
 
     <a-card :bordered="false" class="page-card" :title="isEdit ? '编辑优惠码' : '新建优惠码'">
-      <a-spin :loading="pageLoading">
+      <a-skeleton animation :loading="pageLoading">
+        <a-skeleton-line :rows="14" />
+        <template #content>
         <a-form :model="form" layout="vertical" @submit-success="handleSubmit">
           <a-row :gutter="24">
             <a-col :xs="24" :md="8">
@@ -132,7 +134,8 @@
             </a-space>
           </a-form-item>
         </a-form>
-      </a-spin>
+        </template>
+      </a-skeleton>
     </a-card>
   </div>
 </template>

+ 8 - 4
src/pages/admin/goods/orderDetail.vue

@@ -1,8 +1,10 @@
 <template>
   <div class="admin-order-detail">
     <Breadcrumb />
-    <a-spin :loading="loading" style="width: 100%">
-      <div v-if="!loading && data.orderId" class="detail-grid">
+    <a-skeleton animation :loading="loading" style="width: 100%">
+      <StoreDetailSkeleton variant="order" />
+      <template #content>
+        <div v-if="data.orderId" class="detail-grid">
         <a-card :bordered="false" class="detail-card detail-card--status">
           <template #title>订单进度</template>
           <OrderProgressSteps
@@ -63,8 +65,9 @@
           <template #title>商品详情</template>
           <div class="goods-content" v-html="goodsContent" />
         </a-card>
-      </div>
-    </a-spin>
+        </div>
+      </template>
+    </a-skeleton>
   </div>
 </template>
 
@@ -74,6 +77,7 @@ import { useRoute } from 'vue-router'
 import { adminOrderDetail } from '@/api/order'
 import { Notification } from '@arco-design/web-vue'
 import OrderProgressSteps from '@/components/store/OrderProgressSteps.vue'
+import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
 import {
   formatStoreTimeFull,
   getPayTypeLabel,

+ 9 - 2
src/pages/admin/lepaoProxy/index.vue

@@ -2,7 +2,9 @@
   <div class="lepao-proxy-page">
     <Breadcrumb />
 
-    <a-spin :loading="pageLoading">
+    <a-skeleton animation :loading="pageLoading" class="lepao-proxy-skeleton">
+      <a-skeleton-line :rows="10" />
+      <template #content>
       <a-card class="hero-card" :bordered="false">
         <div class="hero-inner">
           <div class="hero-title">
@@ -190,7 +192,8 @@
           </template>
         </a-table>
       </a-card>
-    </a-spin>
+      </template>
+    </a-skeleton>
   </div>
 </template>
 
@@ -458,6 +461,10 @@ onMounted(async () => {
   margin: 0 auto;
 }
 
+.lepao-proxy-skeleton {
+  width: 100%;
+}
+
 .hero-card {
   background: linear-gradient(135deg, var(--color-bg-2), var(--color-fill-2));
 }

+ 11 - 11
src/pages/admin/lepaoRecords/recordDetail.vue

@@ -3,18 +3,17 @@
         <Breadcrumb />
         <a-card title="记录详情">
             <a-skeleton animation :loading="loading">
-                <a-space direction="vertical" :style="{ width: '100%' }" size="large">
-                    <a-skeleton-shape size="large" />
-                    <a-skeleton-line :rows="5" />
-                </a-space>
+                <StoreRecordSkeleton />
+                <template #content>
+                    <a-descriptions
+                        :data="info"
+                        :column="isMobile ? 1 : 2"
+                        class="record-descriptions"
+                    />
+                    <MapContainer v-if="showMap" :point_list="data.result.point_list" :log_list="data.point_data" :pathData="data.data" threeD
+                        style="margin-top: 10px;" />
+                </template>
             </a-skeleton>
-            <a-descriptions
-                :data="info"
-                :column="isMobile ? 1 : 2"
-                class="record-descriptions"
-            />
-            <MapContainer v-if="showMap" :point_list="data.result.point_list" :log_list="data.point_data" :pathData="data.data" threeD
-                style="margin-top: 10px;" />
         </a-card>
     </div>
 </template>
@@ -25,6 +24,7 @@ import { adminGetRecordDetail } from '@/api/lepao'
 import { Notification } from '@arco-design/web-vue'
 import { useRoute } from 'vue-router'
 import MapContainer from '@/components/Map/MapContainer.vue'
+import StoreRecordSkeleton from '@/components/skeleton/StoreRecordSkeleton.vue'
 
 const route = useRoute()
 const uuidLike = /^[0-9a-fA-F-]{16,}$/

+ 5 - 2
src/pages/admin/user/userList.vue

@@ -148,7 +148,9 @@
 
     <a-modal v-model:visible="permissionVisible" title="权限管理" width="860px" @before-ok="handlePermissionBeforeOk"
         :ok-loading="permissionSaving" :mask-closable="false" draggable esc-to-close closable>
-        <a-spin :loading="permissionLoading">
+        <a-skeleton animation :loading="permissionLoading">
+            <a-skeleton-line :rows="8" />
+            <template #content>
             <a-tabs v-model:active-key="permissionTab">
                 <a-tab-pane key="user" title="用户权限">
                     <a-space direction="vertical" fill>
@@ -214,7 +216,8 @@
                     </div>
                 </a-tab-pane>
             </a-tabs>
-        </a-spin>
+            </template>
+        </a-skeleton>
     </a-modal>
 </template>
 

+ 150 - 0
src/pages/doc/detail.vue

@@ -0,0 +1,150 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <a-skeleton animation :loading="loading" class="doc-detail-skeleton">
+      <StoreDetailSkeleton />
+      <template #content>
+        <article v-if="article" class="doc-article">
+          <header class="doc-article__header">
+            <div class="doc-article__title-row">
+              <GoodsEmoji :icon="article.cover" size="xl" :fallback="DEFAULT_ARTICLE_EMOJI" :aria-label="article.title" />
+              <h1 class="store-section-title">{{ article.title }}</h1>
+            </div>
+            <div class="doc-article__meta">
+              <a-tag size="small">{{ categoryName }}</a-tag>
+              <span>{{ formatTime(article.time) }}</span>
+            </div>
+          </header>
+          <div class="rich-content" v-html="article.content" />
+        </article>
+        <a-empty v-else description="文档不存在或已下线">
+          <a-button type="primary" @click="$router.push('/doc')">返回文档中心</a-button>
+        </a-empty>
+      </template>
+    </a-skeleton>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { Notification } from '@arco-design/web-vue'
+import { getArticle, getArticleCategories } from '@/api/article'
+import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
+import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
+import { DEFAULT_ARTICLE_EMOJI } from '@/utils/storeFormat'
+
+const route = useRoute()
+const loading = ref(false)
+const article = ref(null)
+const categories = ref([])
+
+const categoryName = computed(() => {
+  if (!article.value) return ''
+  const cat = categories.value.find(c => c.slug === article.value.type)
+  return cat?.name || article.value.type || '文档'
+})
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleString('zh-CN')
+}
+
+const loadCategories = async () => {
+  const res = await getArticleCategories()
+  if (res?.code === 0) categories.value = res.data || []
+}
+
+const loadArticle = async () => {
+  const slug = route.params.slug
+  if (!slug) return
+  loading.value = true
+  article.value = null
+  try {
+    const res = await getArticle({ slug })
+    if (!res || res.code !== 0) {
+      return
+    }
+    article.value = res.data
+    if (article.value?.title) {
+      document.title = `${article.value.title} - RunForge`
+    }
+  } catch (e) {
+    Notification.error({ title: '加载文档失败', content: e.message || '请稍后再试' })
+  } finally {
+    loading.value = false
+  }
+}
+
+watch(() => route.params.slug, loadArticle)
+onMounted(async () => {
+  await loadCategories()
+  await loadArticle()
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.doc-detail-skeleton {
+  width: 100%;
+}
+
+.doc-article {
+  background: @store-card-bg;
+  border: 1px solid @store-card-border;
+  border-radius: @store-radius;
+  padding: clamp(16px, 4vw, 32px);
+  box-shadow: @store-shadow;
+
+  &__header {
+    margin-bottom: 20px;
+  }
+
+  &__title-row {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    flex-wrap: wrap;
+  }
+
+  &__meta {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px 16px;
+    font-size: 0.8rem;
+    color: @store-text-muted;
+    margin-top: 12px;
+  }
+}
+
+.rich-content {
+  line-height: 1.75;
+  color: @store-text-muted;
+  word-break: break-word;
+
+  :deep(img) {
+    max-width: 100%;
+    height: auto;
+  }
+
+  :deep(h1),
+  :deep(h2),
+  :deep(h3) {
+    color: @store-primary;
+    margin-top: 1.25em;
+  }
+
+  :deep(a) {
+    color: @store-accent;
+  }
+
+  :deep(pre),
+  :deep(code) {
+    background: @store-bg;
+    border-radius: 4px;
+  }
+}
+</style>

+ 220 - 0
src/pages/doc/index.vue

@@ -0,0 +1,220 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+
+    <header class="page-header">
+      <div>
+        <h1 class="store-section-title">文档中心</h1>
+        <p class="store-section-desc">使用指南、操作说明与常见问题</p>
+      </div>
+    </header>
+
+    <a-skeleton animation :loading="categoriesLoading" class="doc-tabs-skeleton">
+      <a-skeleton-line :widths="['88px', '96px', '80px', '72px']" :rows="1" />
+      <template #content>
+        <a-tabs v-if="categories.length" v-model:active-key="activeType" @change="onTabChange">
+          <a-tab-pane v-for="cat in categories" :key="cat.slug">
+            <template #title>
+              <span>{{ cat.icon || '' }} {{ cat.name }}</span>
+            </template>
+          </a-tab-pane>
+        </a-tabs>
+        <a-empty v-else description="暂无文档分类" />
+      </template>
+    </a-skeleton>
+
+    <a-skeleton animation :loading="listLoading" class="doc-list-skeleton">
+      <StoreGridSkeleton :count="6" />
+      <template #content>
+        <div v-if="articles.length" class="doc-grid">
+          <article
+            v-for="item in articles"
+            :key="item.slug"
+            class="doc-card"
+            @click="goDetail(item.slug)"
+          >
+            <div class="doc-card__visual">
+              <GoodsEmoji :icon="item.cover" size="lg" :fallback="DEFAULT_ARTICLE_EMOJI" :aria-label="item.title" />
+            </div>
+            <div class="doc-card__body">
+              <h3 class="doc-card__title">{{ item.title }}</h3>
+              <p v-if="item.describe" class="doc-card__desc">{{ item.describe }}</p>
+              <div class="doc-card__meta">
+                <span>{{ item.author || 'RunForge' }}</span>
+                <span>{{ formatTime(item.time) }}</span>
+                <!-- <span>{{ item.views ?? 0 }} 次阅读</span> -->
+              </div>
+            </div>
+          </article>
+        </div>
+        <a-empty v-else-if="activeType" description="该分类下暂无文档" />
+      </template>
+    </a-skeleton>
+
+    <div v-if="pagination.total > pagination.pagesize" class="doc-pagination">
+      <a-pagination
+        :total="pagination.total"
+        :current="pagination.current"
+        :page-size="pagination.pagesize"
+        show-total
+        @change="onPageChange"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { Notification } from '@arco-design/web-vue'
+import { getArticleCategories, getArticleList } from '@/api/article'
+import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
+import StoreGridSkeleton from '@/components/skeleton/StoreGridSkeleton.vue'
+import { DEFAULT_ARTICLE_EMOJI } from '@/utils/storeFormat'
+
+const router = useRouter()
+const categories = ref([])
+const categoriesLoading = ref(false)
+const listLoading = ref(false)
+const articles = ref([])
+const activeType = ref('')
+const pagination = reactive({ total: 0, current: 1, pagesize: 12 })
+
+const formatTime = (time) => {
+  if (!time) return '-'
+  return new Date(Number(time)).toLocaleDateString('zh-CN')
+}
+
+const loadCategories = async () => {
+  categoriesLoading.value = true
+  try {
+    const res = await getArticleCategories()
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '加载分类失败', content: res?.msg ?? '请稍后再试' })
+      return
+    }
+    categories.value = res.data || []
+    if (categories.value.length) {
+      activeType.value = categories.value[0].slug
+      await loadArticles()
+    }
+  } finally {
+    categoriesLoading.value = false
+  }
+}
+
+const loadArticles = async () => {
+  if (!activeType.value) return
+  listLoading.value = true
+  try {
+    const res = await getArticleList({
+      type: activeType.value,
+      current: pagination.current,
+      pagesize: pagination.pagesize
+    })
+    if (!res || res.code !== 0) {
+      Notification.error({ title: '加载文档失败', content: res?.msg ?? '请稍后再试' })
+      return
+    }
+    articles.value = res.data || []
+    pagination.total = res.pagination?.total || 0
+  } finally {
+    listLoading.value = false
+  }
+}
+
+const onTabChange = () => {
+  pagination.current = 1
+  loadArticles()
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  loadArticles()
+}
+
+const goDetail = (slug) => {
+  router.push(`/doc/${slug}`)
+}
+
+onMounted(loadCategories)
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.doc-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
+  gap: 16px;
+}
+
+.doc-card {
+  background: @store-card-bg;
+  border: 1px solid @store-card-border;
+  border-radius: @store-radius;
+  overflow: hidden;
+  cursor: pointer;
+  transition: transform 0.2s, box-shadow 0.2s;
+
+  &:hover {
+    transform: translateY(-2px);
+    box-shadow: @store-shadow-hover;
+  }
+
+  &__visual {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    min-height: 120px;
+    background: fade(@store-accent, 8%);
+    border-bottom: 1px solid @store-card-border;
+  }
+
+  &__body {
+    padding: 16px;
+  }
+
+  &__title {
+    margin: 0 0 8px;
+    font-size: 1.05rem;
+    color: @store-primary;
+    font-weight: 600;
+    line-height: 1.4;
+  }
+
+  &__desc {
+    margin: 0 0 12px;
+    font-size: 0.875rem;
+    color: @store-text-muted;
+    line-height: 1.5;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  &__meta {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px 12px;
+    font-size: 0.75rem;
+    color: fade(@store-text-muted, 80%);
+  }
+}
+
+.doc-tabs-skeleton {
+  width: 100%;
+}
+
+.doc-list-skeleton {
+  width: 100%;
+  margin-top: 16px;
+}
+
+.doc-pagination {
+  margin-top: 24px;
+  display: flex;
+  justify-content: center;
+}
+</style>

File diff suppressed because it is too large
+ 49 - 73
src/pages/download/index.vue


+ 8 - 54
src/pages/lepao/accountList/index.vue

@@ -13,28 +13,14 @@
           添加账号
         </a-button>
 
-        <a-button size="large" @click="download('windows')">
+        <a-button size="large" @click="$router.push('/doc')">
           <template #icon>
-            <icon-desktop />
+            <icon-question-circle />
           </template>
-          Windows操作说明
+          操作说明
         </a-button>
 
-        <a-button size="large" @click="download('android')">
-          <template #icon>
-            <icon-mobile />
-          </template>
-          安卓手机操作说明
-        </a-button>
-
-        <a-button size="large" @click="download('iphone')">
-          <template #icon>
-            <icon-mobile />
-          </template>
-          iPhone操作说明
-        </a-button>
-
-        <a-button size="large" @click="download('page')" v-if="!isElectron()">
+        <a-button size="large" @click="$router.push('/download')" v-if="!isElectron()">
           <template #icon>
             <icon-download />
           </template>
@@ -55,19 +41,11 @@
             <icon-down />
           </a-button>
           <template #content>
-            <a-doption @click="download('windows')">
-              <icon-desktop />
-              Windows操作说明
+            <a-doption @click="$router.push('/doc')">
+              <icon-question-circle />
+              操作说明
             </a-doption>
-            <a-doption @click="download('android')">
-              <icon-mobile />
-              安卓手机操作说明
-            </a-doption>
-            <a-doption @click="download('iphone')">
-              <icon-mobile />
-              iPhone操作说明
-            </a-doption>
-            <a-doption v-if="!isElectron()" @click="download('page')">
+            <a-doption v-if="!isElectron()" @click="$router.push('/download')">
               <icon-download />
               客户端/登录器下载
             </a-doption>
@@ -556,30 +534,6 @@ const form = reactive({
   notes: ''
 })
 
-const download = (device) => {
-  const a = document.createElement('a')
-
-  if (device === 'windows') {
-    a.href = 'https:\/\/lepao-cloud.xxoo365.top/down.php\/682d99f9694c6fe76b64b86c5741a2d8.pdf'
-    a.download = 'RunForge-Windows操作说明.pdf'
-  } else if (device === 'iphone') {
-    a.href = 'https:\/\/lepao-cloud.xxoo365.top/down.php\/fba1d571166b4c95592c7c4e624a9390.pdf'
-    a.download = 'RunForge-iPhone操作说明.pdf'
-  } else if (device === 'android') {
-    a.href = 'https:\/\/lepao-cloud.xxoo365.top\/down.php\/3326850aa879cea586677a15af470beb.pdf'
-    a.download = 'RunForge-Android操作说明.pdf'
-  } else if (device === 'page') {
-    a.href = 'https:\/\/download.xxoo365.top'
-  } else {
-    return
-  }
-
-  a.target = '_blank'
-  document.body.appendChild(a)
-  a.click()
-  document.body.removeChild(a)
-}
-
 const normalizeText = (value) => (value || '').replace(/[\n\r\t,;、]/g, ' ').trim()
 
 const matchArea = (text) => {

+ 11 - 11
src/pages/lepao/lepaoRecords/recordDetail.vue

@@ -3,18 +3,17 @@
         <Breadcrumb />
         <a-card title="记录详情">
             <a-skeleton animation :loading="loading">
-                <a-space direction="vertical" :style="{ width: '100%' }" size="large">
-                    <a-skeleton-shape size="large" />
-                    <a-skeleton-line :rows="5" />
-                </a-space>
+                <StoreRecordSkeleton />
+                <template #content>
+                    <a-descriptions
+                        :data="info"
+                        :column="isMobile ? 1 : 2"
+                        class="record-descriptions"
+                    />
+                    <MapContainer v-if="showMap" :point_list="data.result.point_list" :log_list="data.point_data" :pathData="data.data" threeD
+                        style="margin-top: 10px;" />
+                </template>
             </a-skeleton>
-            <a-descriptions
-                :data="info"
-                :column="isMobile ? 1 : 2"
-                class="record-descriptions"
-            />
-            <MapContainer v-if="showMap" :point_list="data.result.point_list" :log_list="data.point_data" :pathData="data.data" threeD
-                style="margin-top: 10px;" />
         </a-card>
     </div>
 </template>
@@ -25,6 +24,7 @@ import { GetRecordDetail } from '@/api/lepao'
 import { Notification } from '@arco-design/web-vue'
 import { useRoute } from 'vue-router'
 import MapContainer from '@/components/Map/MapContainer.vue'
+import StoreRecordSkeleton from '@/components/skeleton/StoreRecordSkeleton.vue'
 
 const route = useRoute()
 const uuidLike = /^[0-9a-fA-F-]{16,}$/

+ 15 - 10
src/pages/power/accountList.vue

@@ -16,12 +16,14 @@
 
       <a-alert v-if="notice" type="info" closable class="notice">{{ notice }}</a-alert>
 
-      <a-spin :loading="loading" class="store-spin">
-        <a-empty v-if="!loading && data.length === 0" description="暂无提醒任务">
-          <a-button type="primary" class="primary-btn" @click="editAccount()">添加第一个任务</a-button>
-        </a-empty>
-
-        <div v-else class="account-list">
+      <a-skeleton animation :loading="loading" class="store-spin">
+        <StoreStackSkeleton :count="3" :show-action="false" />
+        <template #content>
+          <a-empty v-if="data.length === 0" description="暂无提醒任务">
+            <a-button type="primary" class="primary-btn" @click="editAccount()">添加第一个任务</a-button>
+          </a-empty>
+
+          <div v-else class="account-list">
           <article
             v-for="record in data"
             :key="record.id"
@@ -33,9 +35,9 @@
                 <icon-home class="loc-icon" />
                 <span>{{ record.area }} · {{ record.building }} · {{ record.room }}</span>
               </div>
-              <a-tag color="arcoblue" size="small">
+              <!-- <a-tag color="arcoblue" size="small">
                 扣费时间 {{ record.koufei_date || '-' }}
-              </a-tag>
+              </a-tag> -->
             </div>
 
             <div class="account-card__stats">
@@ -52,6 +54,7 @@
             <div class="account-card__meta">
               <span><icon-email /> 通知邮箱:{{ record.email }}</span>
               <span><icon-clock-circle /> 更新时间:{{ formatTime(record.update_time) }}</span>
+              <span><icon-clock-circle /> 扣费时间:{{ record.koufei_date || '-' }}</span>
             </div>
 
             <p v-if="record.notes" class="account-card__notes">{{ record.notes }}</p>
@@ -73,8 +76,9 @@
               </a-dropdown>
             </div>
           </article>
-        </div>
-      </a-spin>
+          </div>
+        </template>
+      </a-skeleton>
     </div>
   </div>
 
@@ -182,6 +186,7 @@ import { Modal, Notification, Message } from '@arco-design/web-vue'
 import { useRoute } from 'vue-router'
 import { getNotice } from '@/utils/util'
 import { formatStoreTimeFull } from '@/utils/storeFormat'
+import StoreStackSkeleton from '@/components/skeleton/StoreStackSkeleton.vue'
 
 const notice = ref('')
 

+ 7 - 3
src/pages/service/orderDetail.vue

@@ -3,8 +3,10 @@
     <div class="service-page__inner service-page__inner--wide">
       <Breadcrumb />
 
-      <a-spin :loading="loading" class="store-spin">
-        <template v-if="!loading && data.id">
+      <a-skeleton animation :loading="loading" class="store-spin">
+        <StoreDetailSkeleton variant="order" />
+        <template #content>
+          <template v-if="data.id">
           <a-card :bordered="false" class="info-card">
             <div class="info-card__head">
               <div>
@@ -97,8 +99,9 @@
               </a-form-item>
             </a-form>
           </a-card>
+          </template>
         </template>
-      </a-spin>
+      </a-skeleton>
     </div>
   </div>
 </template>
@@ -111,6 +114,7 @@ import { useRoute } from 'vue-router'
 import { hasPermission } from '@/utils/permission'
 import { formatStoreTimeFull } from '@/utils/storeFormat'
 import WorkOrderStateTag from '@/components/service/WorkOrderStateTag.vue'
+import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
 
 const route = useRoute()
 const loading = ref(false)

+ 24 - 20
src/pages/service/orderList.vue

@@ -16,12 +16,14 @@
 
       <a-alert v-if="notice" type="info" closable class="notice">{{ notice }}</a-alert>
 
-      <a-spin :loading="loading" class="store-spin">
-        <a-empty v-if="!loading && data.length === 0" description="暂无工单">
-          <a-button type="primary" @click="$router.push('/service/createOrder')">去提交</a-button>
-        </a-empty>
-
-        <div v-else class="ticket-list">
+      <a-skeleton animation :loading="loading" class="store-spin">
+        <StoreStackSkeleton :count="4" :show-meta="true" :show-action="false" :header-widths="['20%', '30%']" />
+        <template #content>
+          <a-empty v-if="data.length === 0" description="暂无工单">
+            <a-button type="primary" @click="$router.push('/service/createOrder')">去提交</a-button>
+          </a-empty>
+
+          <div v-else class="ticket-list">
           <article
             v-for="record in data"
             :key="record.id"
@@ -43,20 +45,21 @@
               </a-button>
             </div>
           </article>
-        </div>
-
-        <div v-if="pagination.total > pagination.pagesize" class="pager">
-          <a-pagination
-            :total="pagination.total"
-            :current="pagination.current"
-            :page-size="pagination.pagesize"
-            show-total
-            show-page-size
-            @change="handlePageChange"
-            @page-size-change="handlePageSizeChange"
-          />
-        </div>
-      </a-spin>
+          </div>
+
+          <div v-if="pagination.total > pagination.pagesize" class="pager">
+            <a-pagination
+              :total="pagination.total"
+              :current="pagination.current"
+              :page-size="pagination.pagesize"
+              show-total
+              show-page-size
+              @change="handlePageChange"
+              @page-size-change="handlePageSizeChange"
+            />
+          </div>
+        </template>
+      </a-skeleton>
     </div>
   </div>
 </template>
@@ -69,6 +72,7 @@ import { useRoute } from 'vue-router'
 import { getNotice } from '@/utils/util'
 import { formatStoreTime } from '@/utils/storeFormat'
 import WorkOrderStateTag from '@/components/service/WorkOrderStateTag.vue'
+import StoreStackSkeleton from '@/components/skeleton/StoreStackSkeleton.vue'
 
 const notice = ref('')
 const loading = ref(false)

+ 12 - 8
src/pages/store/goodsDetail/index.vue

@@ -2,13 +2,15 @@
   <div class="store-page goods-detail-page app-page--wide">
     <Breadcrumb />
 
-    <a-spin :loading="loading" class="store-spin">
-      <div v-if="loadError" class="load-error">
-        <a-empty description="商品加载失败">
-          <a-button type="primary" @click="getGoodsDetail">重新加载</a-button>
-        </a-empty>
-      </div>
-      <div v-else-if="data?.id" class="detail-layout">
+    <a-skeleton animation :loading="loading" class="store-spin">
+      <StoreDetailSkeleton variant="goods" />
+      <template #content>
+        <div v-if="loadError" class="load-error">
+          <a-empty description="商品加载失败">
+            <a-button type="primary" @click="getGoodsDetail">重新加载</a-button>
+          </a-empty>
+        </div>
+        <div v-else-if="data?.id" class="detail-layout">
           <aside class="detail-summary">
             <div class="summary-card">
               <div class="summary-visual">
@@ -53,7 +55,8 @@
             </a-card>
           </main>
         </div>
-    </a-spin>
+      </template>
+    </a-skeleton>
 
     <div v-if="!loading && data?.num > 0" class="mobile-buy-bar">
       <span class="mobile-price">¥{{ payablePrice }}</span>
@@ -135,6 +138,7 @@ import { useRoute, useRouter } from 'vue-router'
 import { Notification, Message } from '@arco-design/web-vue'
 import PayMethodPicker from '@/components/store/PayMethodPicker.vue'
 import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
+import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
 import { stockLabel, parseFeatures, decodeGoodsContent } from '@/utils/storeFormat'
 
 const route = useRoute()

+ 9 - 5
src/pages/store/goodsList/index.vue

@@ -23,9 +23,11 @@
       </div>
     </header>
 
-    <a-spin :loading="loading" class="store-spin goods-spin">
-      <a-empty v-if="!loading && filteredData.length === 0" description="暂无商品" />
-      <div v-else class="goods-grid">
+    <a-skeleton animation :loading="loading" class="store-spin goods-spin">
+      <StoreGridSkeleton :count="6" />
+      <template #content>
+        <a-empty v-if="filteredData.length === 0" description="暂无商品" />
+        <div v-else class="goods-grid">
         <article
           v-for="item in filteredData"
           :key="item.id"
@@ -53,8 +55,9 @@
             立即选购
           </a-button>
         </article>
-      </div>
-    </a-spin>
+        </div>
+      </template>
+    </a-skeleton>
   </div>
 </template>
 
@@ -65,6 +68,7 @@ import { getGoodsList } from '@/api/goods'
 import { Notification } from '@arco-design/web-vue'
 import userCard from '@/components/userCard/userCard.vue'
 import GoodsEmoji from '@/components/store/GoodsEmoji.vue'
+import StoreGridSkeleton from '@/components/skeleton/StoreGridSkeleton.vue'
 import { getNotice } from '@/utils/util'
 import { stockLabel, parseFeatures } from '@/utils/storeFormat'
 

+ 7 - 3
src/pages/store/orders/orderDetail/index.vue

@@ -3,8 +3,10 @@
     <div class="order-detail-page__inner app-page__inner--wide">
       <Breadcrumb />
 
-      <a-spin :loading="loading" class="store-spin">
-        <div class="detail-stack">
+      <a-skeleton animation :loading="loading" class="store-spin">
+        <StoreDetailSkeleton variant="order" />
+        <template #content>
+          <div class="detail-stack">
         <!-- 待支付横幅 -->
         <div v-if="data?.state === 0 && hasPay" class="pay-banner">
           <div class="pay-banner__info">
@@ -89,7 +91,8 @@
           <a-button type="outline" @click="$router.push('/store/goodsList')">继续购物</a-button>
         </div>
         </div>
-      </a-spin>
+        </template>
+      </a-skeleton>
     </div>
   </div>
 </template>
@@ -101,6 +104,7 @@ import { useRoute } from 'vue-router'
 import { Notification, Message, Modal } from '@arco-design/web-vue'
 import OrderStateTag from '@/components/store/OrderStateTag.vue'
 import OrderProgressSteps from '@/components/store/OrderProgressSteps.vue'
+import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
 import {
   formatStoreTimeFull,
   getPayTypeLabel,

+ 11 - 7
src/pages/store/orders/orderList/index.vue

@@ -20,12 +20,14 @@
         <a-tab-pane v-for="tab in statusTabs" :key="tab.key" :title="tab.title" />
       </a-tabs>
 
-      <a-spin :loading="loading" class="store-spin">
-        <a-empty v-if="!loading && displayList.length === 0" description="暂无相关订单">
-          <a-button type="primary" @click="$router.push('/store/goodsList')">去选购</a-button>
-        </a-empty>
+      <a-skeleton animation :loading="loading" class="store-spin">
+        <StoreStackSkeleton :count="4" :show-action="false" :header-widths="['40%', '20%']" />
+        <template #content>
+          <a-empty v-if="displayList.length === 0" description="暂无相关订单">
+            <a-button type="primary" @click="$router.push('/store/goodsList')">去选购</a-button>
+          </a-empty>
 
-        <div v-else class="order-list">
+          <div v-else class="order-list">
           <article
             v-for="record in displayList"
             :key="record.orderId"
@@ -72,8 +74,9 @@
               </a-button>
             </div>
           </article>
-        </div>
-      </a-spin>
+          </div>
+        </template>
+      </a-skeleton>
     </div>
   </div>
 </template>
@@ -84,6 +87,7 @@ import { useRouter, useRoute } from 'vue-router'
 import { getMyOrder, cancelOrder } from '@/api/order'
 import { Notification, Message, Modal } from '@arco-design/web-vue'
 import OrderStateTag from '@/components/store/OrderStateTag.vue'
+import StoreStackSkeleton from '@/components/skeleton/StoreStackSkeleton.vue'
 import { getNotice } from '@/utils/util'
 import { formatStoreTime, getPayTypeLabel } from '@/utils/storeFormat'
 

+ 8 - 40
src/pages/store/sendCountRecords/index.vue

@@ -4,27 +4,27 @@
 
     <a-card title="赠送记录">
       <AppQueryFilter>
-      <a-row class="query-row">
-        <a-col :flex="'1000px'" class="query-main">
-          <a-form class="queryForm app-query-form" :model="queryData" :label-col-props="{ span: 6 }"
+      <a-row class="queryForm app-query-form">
+        <a-col :flex="'1000px'">
+          <a-form :model="queryData" :label-col-props="{ span: 6 }"
             :wrapper-col-props="{ span: 18 }" label-align="left">
             <a-row :gutter="16">
               <a-col :span="12">
                 <a-form-item field="direction" label="记录类型">
-                  <a-select v-model="queryData.direction" :options="directionOptions" placeholder="请选择记录类型" />
+                  <a-select v-model="queryData.direction" :options="directionOptions" placeholder="请选择记录类型" style="width: 100%" />
                 </a-form-item>
               </a-col>
               <a-col :span="12">
                 <a-form-item field="status" label="审核状态">
-                  <a-select v-model="queryData.status" :options="statusOptions" placeholder="请选择审核状态" />
+                  <a-select v-model="queryData.status" :options="statusOptions" placeholder="请选择审核状态" style="width: 100%" />
                 </a-form-item>
               </a-col>
             </a-row>
           </a-form>
         </a-col>
-        <a-divider class="query-divider" style="height: 84px" direction="vertical" />
-        <a-col :flex="1" class="app-query-actions">
-          <a-space class="query-actions-space" direction="vertical" :size="18">
+        <a-divider style="height: 84px" direction="vertical" />
+        <a-col :flex="'86px'" style="text-align: right">
+          <a-space direction="vertical" :size="18">
             <a-button type="primary" @click="search">
               <template #icon>
                 <icon-search />
@@ -194,39 +194,7 @@ onMounted(() => {
 </script>
 
 <style scoped lang="less">
-.query-row {
-  margin-bottom: 10px;
-}
-
-.query-main {
-  min-width: 0;
-}
-
-.query-actions-space {
-  width: 100%;
-}
-
 .table {
   margin-top: 15px;
 }
-
-@media (max-width: 768px) {
-  .query-row {
-    display: block;
-  }
-
-  .query-divider {
-    display: none;
-  }
-
-  .query-actions-space {
-    margin-top: 4px;
-    flex-direction: row !important;
-    gap: 10px !important;
-  }
-
-  .query-actions-space :deep(.arco-btn) {
-    flex: 1;
-  }
-}
 </style>

+ 101 - 8
src/router/index.js

@@ -13,7 +13,8 @@ const routes = [
         component: () => import('../pages/Main/Main.vue'),
         meta: {
             hideInMenu: true,
-            onlyWeb: true
+            onlyWeb: true,
+            public: true
         }
     },
     {
@@ -23,6 +24,7 @@ const routes = [
         meta: {
             hideInMenu: true,
             onlyWeb: true,
+            public: true,
             viewport: 'width=device-width, initial-scale=1'
         }
     },
@@ -32,7 +34,8 @@ const routes = [
         component: () => import('../pages/login/login.vue'),
         meta: {
             title: '用户登录',
-            hideInMenu: true
+            hideInMenu: true,
+            public: true
         }
     },
     {
@@ -69,7 +72,8 @@ const routes = [
                 name: 'htmlView.view',
                 component: () => import('../pages/htmlView/index.vue'),
                 meta: {
-                    title: '第三方页面'
+                    title: '第三方页面',
+                    public: true
                 }
             }
         ]
@@ -139,7 +143,7 @@ const routes = [
         component: DEFAULT_LAYOUT,
         meta: {
             title: '校园乐跑',
-            icon: 'icon-dashboard'
+            icon: 'icon-sun'
         },
         children: [
             {
@@ -193,7 +197,8 @@ const routes = [
                 name: 'qxs.getBookList',
                 component: () => import('../pages/qxs/getBookList.vue'),
                 meta: {
-                    title: '书单查询'
+                    title: '书单查询',
+                    public: true
                 }
             }
         ]
@@ -427,7 +432,7 @@ const routes = [
         meta: {
             title: '公告管理',
             icon: 'icon-notification',
-            permission: ['admin', 'service']
+            permission: ['admin', 'service', 'article']
         },
         children: [
             {
@@ -447,6 +452,33 @@ const routes = [
                     title: '弹窗公告',
                     permission: ['admin', 'service']
                 }
+            },
+            {
+                path: 'article',
+                name: 'noticeManage.article',
+                component: () => import('../pages/admin/article/index.vue'),
+                meta: {
+                    title: '文章管理',
+                    permission: ['admin', 'article']
+                }
+            },
+            {
+                path: 'articleCategory',
+                name: 'noticeManage.articleCategory',
+                component: () => import('../pages/admin/articleCategory/index.vue'),
+                meta: {
+                    title: '文档分类',
+                    permission: ['admin', 'article']
+                }
+            },
+            {
+                path: 'download',
+                name: 'noticeManage.download',
+                component: () => import('../pages/admin/download/index.vue'),
+                meta: {
+                    title: '下载项管理',
+                    permission: ['admin', 'service']
+                }
             }
         ]
     },
@@ -593,6 +625,67 @@ const routes = [
                 },
             }
         ]
+    },
+    {
+        path: "/doc",
+        name: "docLayout",
+        component: DEFAULT_LAYOUT,
+        redirect: "/doc/list",
+        meta: {
+            title: '文档中心',
+            icon: 'icon-file',
+            public: true,
+            flatMenu: true
+        },
+        children: [
+            {
+                path: "list",
+                name: "doc",
+                alias: "/doc",
+                component: () => import('../pages/doc/index.vue'),
+                meta: {
+                    title: '文档中心',
+                    public: true
+                }
+            },
+            {
+                path: ":slug",
+                name: "doc.detail",
+                component: () => import('../pages/doc/detail.vue'),
+                meta: {
+                    title: '文档详情',
+                    public: true,
+                    hideInMenu: true,
+                    parent: 'doc'
+                }
+            }
+        ]
+    },
+    {
+        path: "/download",
+        name: "downloadLayout",
+        component: DEFAULT_LAYOUT,
+        redirect: "/download/index",
+        meta: {
+            title: '下载中心',
+            icon: 'icon-download',
+            public: true,
+            onlyWeb: true,
+            flatMenu: true
+        },
+        children: [
+            {
+                path: "index",
+                name: "download",
+                alias: "/download",
+                component: () => import('../pages/download/index.vue'),
+                meta: {
+                    title: '下载中心',
+                    public: true,
+                    onlyWeb: true
+                }
+            }
+        ]
     }
 ]
 
@@ -601,10 +694,10 @@ const router = VueRouter.createRouter({
     routes: routes
 })
 
-const allow = ['/', '/login', '/qxs/getBookList', '/htmlView/view', '/download/down', '/faceReco']
+const isPublicRoute = (to) => to.matched.some(r => r.meta?.public)
 
 router.beforeEach(async (to, from, next) => {
-    if (!allow.includes(to.path)) {
+    if (!isPublicRoute(to)) {
         const userStore = useUserStore()
         let user = await userStore.getInfo()
 

+ 18 - 0
src/utils/permission.js

@@ -62,3 +62,21 @@ export const canAccessRoute = (route, user) => {
   if (!meta.permission || meta.permission.length === 0) return true
   return meta.permission.some(perm => (currentUser.roles || []).includes(perm))
 }
+
+export const isLoggedIn = (user) => !!(user?.uuid && user?.session)
+
+/** 路由或其可见子路由是否为免登录公开页 */
+export const hasPublicMenuDescendant = (route) => {
+  if (!route || route.meta?.hideInMenu) return false
+  if (route.meta?.public) return true
+  if (!route.children?.length) return false
+  return route.children.some((child) => hasPublicMenuDescendant(child))
+}
+
+export const canShowInMenu = (route, user) => {
+  if (!route || route.meta?.hideInMenu) return false
+  if (!canAccessRoute(route, user)) return false
+  if (isLoggedIn(user)) return true
+  if (route.meta?.public) return true
+  return hasPublicMenuDescendant(route)
+}

+ 27 - 0
src/utils/slugify.js

@@ -0,0 +1,27 @@
+const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$|^[a-z0-9]{3}$/
+
+function simpleHash(str) {
+  let h = 0
+  for (let i = 0; i < str.length; i++) {
+    h = ((h << 5) - h) + str.charCodeAt(i)
+    h |= 0
+  }
+  return Math.abs(h).toString(36)
+}
+
+export function slugify(title) {
+  let base = String(title || '').trim().toLowerCase()
+    .replace(/\s+/g, '-')
+    .replace(/[^a-z0-9-]/g, '')
+    .replace(/-+/g, '-')
+    .replace(/^-|-$/g, '')
+
+  if (base.length < 3) {
+    base = 'article-' + simpleHash(String(title || Date.now()))
+  }
+  return base.slice(0, 58)
+}
+
+export function isValidSlug(slug) {
+  return SLUG_PATTERN.test(String(slug || ''))
+}

+ 6 - 0
src/utils/storeFormat.js

@@ -44,6 +44,7 @@ export function getOrderStateMeta(state) {
 }
 
 export const DEFAULT_GOODS_EMOJI = '🏃'
+export const DEFAULT_ARTICLE_EMOJI = '📄'
 
 /** 商品展示用 emoji(icon 字段存 emoji,若为图片 URL 则回退默认) */
 export function getGoodsEmoji(icon, fallback = DEFAULT_GOODS_EMOJI) {
@@ -56,6 +57,11 @@ export function getGoodsEmoji(icon, fallback = DEFAULT_GOODS_EMOJI) {
   return value
 }
 
+/** 文章封面 emoji(cover 字段存 emoji,旧图片 URL 回退默认) */
+export function getArticleEmoji(cover, fallback = DEFAULT_ARTICLE_EMOJI) {
+  return getGoodsEmoji(cover, fallback)
+}
+
 export function stockLabel(num) {
   if (num == null) return '-'
   return num > 99 ? '充足' : String(num)

Some files were not shown because too many files changed in this diff