zlwu před 6 měsíci
rodič
revize
eb942972be

binární
backend.zip


+ 6 - 6
frontend/src/pages/ChatBotPage.tsx

@@ -104,7 +104,7 @@ const conversationEnergyStoneCountBg = getOssUrl('conversion-energy-stone-count.
 const qinmiduImage = getOssUrl('qinmidu.png');
 const YpImage = getOssUrl('yp.png');
 const audioWaveGif = getOssUrl('audio-wave.gif');
-const inboxEllipseImg = getOssUrl('Ellipse-3709.png');
+const inboxEllipseImg = getOssUrl('Lumi-message-2.gif');
 const talkEnergyStoneImg = getOssUrl('talk-energy-stone.png');
 const talkEnergyStoneRewardImg = getOssUrl('talk-energy-stone-1.png');
 const primaryDefaultButton = getOssUrl('Primary--btn.png');
@@ -4970,11 +4970,11 @@ export const ChatBotPage = () => {
               disabled={claimingInboxGift || loadingInboxGiftDetail}
               style={{
                 margin: `${px2vmin(16, 'v2')} auto 0`,
-                width: px2vmin(308, 'v2'),
-                height: px2vmin(56, 'v2'),
+                width: px2vmin(210),
+                height: px2vmin(45),
                 display: 'block',
                 border: 'none',
-                background: `url(${primaryDefaultButton}) center/cover no-repeat`,
+                background: `url(${secondaryDefaultButton}) center/cover no-repeat`,
                 color: '#F5F3F0',
                 fontSize: px2vmin(16, 'v2'),
                 cursor: claimingInboxGift || loadingInboxGiftDetail ? 'default' : 'pointer',
@@ -5072,8 +5072,8 @@ export const ChatBotPage = () => {
               data-sfx-ignore
               onClick={closeInboxRewardModalAndContinue}
               style={{
-                width: px2vmin(312, 'v2'),
-                height: px2vmin(56, 'v2'),
+                width: px2vmin(312),
+                height: px2vmin(60),
                 padding: 0,
                 border: 'none',
                 borderRadius: px2vmin(12, 'v2'),

+ 34 - 28
frontend/src/pages/ProfilePage.tsx

@@ -49,6 +49,7 @@ const closeBtn = getOssUrl('close-btn.png');
 const messageTipsBtn = getOssUrl('message-tips.png');
 const bigNoMessageImg = getOssUrl('big-no-message.png');
 const inboxEllipseImg = getOssUrl('Ellipse-3709.png');
+const inboxExpandedAvatarImg = getOssUrl('Lumi-message-2.gif');
 const talkEnergyStoneImg = getOssUrl('talk-energy-stone.png');
 const talkEnergyStoneRewardImg = getOssUrl('talk-energy-stone-1.png');
 const editIcon = getOssUrl('mode_edit_outline.png');
@@ -2848,7 +2849,7 @@ export const ProfilePage = () => {
                             }}
                           >
                             <img
-                              src={inboxEllipseImg}
+                              src={isExpanded ? inboxExpandedAvatarImg : inboxEllipseImg}
                               alt=""
                               draggable={false}
                               style={{
@@ -2951,31 +2952,36 @@ export const ProfilePage = () => {
                                     {`X${item.energy_stone_amount}`}
                                   </span>
                                 </div>
-                                {!hasClaimed ? (
-                                  <button
-                                    type="button"
-                                    data-sfx-ignore
-                                    onClick={(e) => {
-                                      e.stopPropagation();
-                                      void handleClaimInboxReward(item.id);
-                                    }}
-                                    disabled={processingInboxActionId === item.id}
-                                    style={{
-                                      margin: `${px2vmin(16)} auto 0`,
-                                      width: px2vmin(223),
-                                      height: px2vmin(40),
-                                      display: 'block',
-                                      border: 'none',
-                                      background: `url(${primaryInboxRewardButton}) center/cover no-repeat`,
-                                      color: '#F5F3F0',
-                                      fontSize: px2vmin(16),
-                                      cursor: processingInboxActionId === item.id ? 'default' : 'pointer',
-                                      opacity: processingInboxActionId === item.id ? 0.6 : 1,
-                                    }}
-                                  >
-                                    {i18n.language.startsWith('zh') ? '领取奖励' : 'Claim reward'}
-                                  </button>
-                                ) : null}
+                                <button
+                                  type="button"
+                                  data-sfx-ignore
+                                  onClick={(e) => {
+                                    e.stopPropagation();
+                                    if (hasClaimed || processingInboxActionId === item.id) return;
+                                    void handleClaimInboxReward(item.id);
+                                  }}
+                                  disabled={hasClaimed || processingInboxActionId === item.id}
+                                  style={{
+                                    margin: `${px2vmin(16)} auto 0`,
+                                    width: px2vmin(210),
+                                    height: px2vmin(45),
+                                    display: 'block',
+                                    border: 'none',
+                                    background: `url(${secondaryDefaultButton}) center/cover no-repeat`,
+                                    color: '#F5F3F0',
+                                    fontSize: px2vmin(16),
+                                    cursor: hasClaimed || processingInboxActionId === item.id ? 'default' : 'pointer',
+                                    opacity: hasClaimed || processingInboxActionId === item.id ? 0.6 : 1,
+                                  }}
+                                >
+                                  {hasClaimed
+                                    ? i18n.language.startsWith('zh')
+                                      ? '已领取'
+                                      : 'Claimed'
+                                    : i18n.language.startsWith('zh')
+                                      ? '领取奖励'
+                                      : 'Claim reward'}
+                                </button>
                               </>
                             ) : (
                               <button
@@ -2988,8 +2994,8 @@ export const ProfilePage = () => {
                                 disabled={processingInboxActionId === item.id}
                                 style={{
                                   margin: `${px2vmin(16)} auto 0`,
-                                  width: px2vmin(223),
-                                  height: px2vmin(49),
+                                  width: px2vmin(210),
+                                  height: px2vmin(45),
                                   display: 'block',
                                   border: 'none',
                                   background: `url(${secondaryDefaultButton}) center/cover no-repeat`,

+ 5 - 0
frontend/src/utils/api.ts

@@ -31,6 +31,11 @@ export class ApiError extends Error {
   }
 }
 
+export interface ApiResponse<T> {
+  success: boolean;
+  data: T;
+}
+
 export async function parseApiError(response: Response, fallbackCode = 'UNKNOWN'): Promise<ApiError> {
   let code = fallbackCode;
   let backendMessage: string | undefined;

+ 72 - 0
nginx.conf

@@ -278,4 +278,76 @@ POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
             add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
         }
     }
+
+    # Web Admin 8522 HTTP -> HTTPS
+    server {
+        listen       8522;
+        server_name  www.lyrria.com;
+        return 301 https://$host:8522$request_uri;
+    }
+
+    # Web Admin 独立站点(生产)
+    server {
+        listen       8522 ssl;
+        server_name  www.lyrria.com;
+
+        ssl_certificate "/etc/nginx/cert/lyrria.com.pem";
+        ssl_certificate_key "/etc/nginx/cert/lyrria.com.key";
+
+        ssl_session_cache shared:SSL_WEB_ADMIN:10m;
+        ssl_session_timeout 1h;
+        ssl_protocols TLSv1.2 TLSv1.3;
+        ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
+        ssl_prefer_server_ciphers off;
+
+        # Web Admin 打包目录
+        root /root/Lyrria/web-admin/dist;
+        index index.html;
+
+        # Web Admin API 代理到后端服务
+        location ^~ /api/ {
+            limit_req zone=api_limit burst=40 nodelay;
+            proxy_pass http://127.0.0.1:8766$request_uri;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto $scheme;
+            proxy_read_timeout 60s;
+            proxy_send_timeout 60s;
+            proxy_connect_timeout 10s;
+        }
+
+        # 如 Web Admin 需要 WebSocket,可直接复用
+        location /ws {
+            limit_req zone=ws_limit burst=20 nodelay;
+            proxy_pass http://127.0.0.1:8766;
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection "upgrade";
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto $scheme;
+            proxy_buffering off;
+            proxy_read_timeout 3600s;
+            proxy_send_timeout 3600s;
+            proxy_connect_timeout 10s;
+        }
+
+        # Web Admin 静态资源缓存
+        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ {
+            expires 1y;
+            add_header Cache-Control "public, max-age=31536000, immutable" always;
+            add_header X-Content-Type-Options nosniff always;
+            try_files $uri =404;
+        }
+
+        # SPA 路由回退
+        location / {
+            try_files $uri $uri/ /index.html;
+            add_header X-Frame-Options "SAMEORIGIN" always;
+            add_header X-Content-Type-Options "nosniff" always;
+            add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+        }
+    }
 } 

+ 1 - 0
web-admin/.env.production

@@ -0,0 +1 @@
+VITE_API_BASE_URL=https://www.lyrria.com

+ 84 - 18
web-admin/src/App.vue

@@ -36,6 +36,7 @@ const newsForm = reactive({
 
 const userOptions = ref<Array<{ label: string; value: string }>>([]);
 const loadingUsers = ref(false);
+const selectingAllUsers = ref(false);
 const loadingEnergyList = ref(false);
 const loadingNewsList = ref(false);
 const energyList = ref<AdminInboxListItem[]>([]);
@@ -75,6 +76,7 @@ const isLoadingBarActive = computed(
     loadingNewsList.value ||
     detailLoading.value,
 );
+const selectedUserCount = computed(() => energyForm.selectedUserIds.length);
 
 async function loadUsers(keyword = ""): Promise<void> {
   loadingUsers.value = true;
@@ -91,6 +93,53 @@ async function loadUsers(keyword = ""): Promise<void> {
   }
 }
 
+async function selectAllUsersFromDatabase(): Promise<void> {
+  if (selectingAllUsers.value) {
+    return;
+  }
+  selectingAllUsers.value = true;
+  try {
+    const allUserIds = new Set(energyForm.selectedUserIds);
+    const pageSize = 100;
+    let page = 1;
+    let total = 0;
+    do {
+      const res = await fetchAdminUsers({ page, page_size: pageSize });
+      total = res.data.total;
+      for (const item of res.data.items) {
+        allUserIds.add(item.user_id);
+      }
+      page += 1;
+    } while ((page - 1) * pageSize < total);
+    energyForm.selectedUserIds = Array.from(allUserIds);
+    ElMessage.success(`已全选数据库用户,共 ${energyForm.selectedUserIds.length} 人`);
+  } catch (_error) {
+    ElMessage.error("全选用户失败,请稍后重试");
+  } finally {
+    selectingAllUsers.value = false;
+  }
+}
+
+function toggleVisibleUsers(): void {
+  if (userOptions.value.length === 0) {
+    return;
+  }
+  const visibleUserSet = new Set(userOptions.value.map((option) => option.value));
+  const selectedSet = new Set(energyForm.selectedUserIds);
+  for (const userId of visibleUserSet) {
+    if (selectedSet.has(userId)) {
+      selectedSet.delete(userId);
+    } else {
+      selectedSet.add(userId);
+    }
+  }
+  energyForm.selectedUserIds = Array.from(selectedSet);
+}
+
+function clearSelectedUsers(): void {
+  energyForm.selectedUserIds = [];
+}
+
 function handleMenuSelect(key: string): void {
   activeMenu.value = key as MenuKey;
 }
@@ -358,24 +407,41 @@ loadNewsHistory();
               <el-input-number v-model="energyForm.energyStoneAmount" :min="1" :step="1" :precision="0" />
             </el-form-item>
             <el-form-item label="选择用户">
-              <el-select
-                v-model="energyForm.selectedUserIds"
-                multiple
-                filterable
-                remote
-                reserve-keyword
-                :remote-method="loadUsers"
-                :loading="loadingUsers"
-                placeholder="输入手机号或邮箱检索并选择"
-                style="width: 100%"
-              >
-                <el-option
-                  v-for="item in userOptions"
-                  :key="item.value"
-                  :label="item.label"
-                  :value="item.value"
-                />
-              </el-select>
+              <div style="width: 100%">
+                <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px">
+                  <span style="font-size: 13px; color: #64748b">已选 {{ selectedUserCount }} 人(当前检索 {{ userOptions.length }} 人)</span>
+                  <div style="display: flex; gap: 8px">
+                    <el-button
+                      text
+                      type="primary"
+                      :loading="selectingAllUsers"
+                      @click="selectAllUsersFromDatabase"
+                    >
+                      全选数据库用户
+                    </el-button>
+                    <el-button text type="primary" @click="toggleVisibleUsers">反选当前检索</el-button>
+                    <el-button text @click="clearSelectedUsers">清空已选</el-button>
+                  </div>
+                </div>
+                <el-select
+                  v-model="energyForm.selectedUserIds"
+                  multiple
+                  filterable
+                  remote
+                  reserve-keyword
+                  :remote-method="loadUsers"
+                  :loading="loadingUsers"
+                  placeholder="输入手机号或邮箱检索并选择"
+                  style="width: 100%"
+                >
+                  <el-option
+                    v-for="item in userOptions"
+                    :key="item.value"
+                    :label="item.label"
+                    :value="item.value"
+                  />
+                </el-select>
+              </div>
             </el-form-item>
             <el-form-item>
               <el-button class="submit-btn" type="primary" :loading="submittingEnergy" @click="submitEnergyGift">

+ 72 - 0
生产环境配置/nginx.conf

@@ -277,4 +277,76 @@ OLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
             add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
         }
     }
+
+    # Web Admin 8522 HTTP -> HTTPS
+    server {
+        listen       8522;
+        server_name  www.lyrria.com;
+        return 301 https://$host:8522$request_uri;
+    }
+
+    # Web Admin 独立站点(生产)
+    server {
+        listen       8522 ssl;
+        server_name  www.lyrria.com;
+
+        ssl_certificate "/etc/nginx/cert/lyrria.com.pem";
+        ssl_certificate_key "/etc/nginx/cert/lyrria.com.key";
+
+        ssl_session_cache shared:SSL_WEB_ADMIN:10m;
+        ssl_session_timeout 1h;
+        ssl_protocols TLSv1.2 TLSv1.3;
+        ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
+        ssl_prefer_server_ciphers off;
+
+        # Web Admin 打包目录
+        root /root/Lyrria/web-admin/dist;
+        index index.html;
+
+        # Web Admin API 代理到后端服务
+        location ^~ /api/ {
+            limit_req zone=api_limit burst=40 nodelay;
+            proxy_pass http://127.0.0.1:8766$request_uri;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto $scheme;
+            proxy_read_timeout 60s;
+            proxy_send_timeout 60s;
+            proxy_connect_timeout 10s;
+        }
+
+        # 如 Web Admin 需要 WebSocket,可直接复用
+        location /ws {
+            limit_req zone=ws_limit burst=20 nodelay;
+            proxy_pass http://127.0.0.1:8766;
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection "upgrade";
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto $scheme;
+            proxy_buffering off;
+            proxy_read_timeout 3600s;
+            proxy_send_timeout 3600s;
+            proxy_connect_timeout 10s;
+        }
+
+        # Web Admin 静态资源缓存
+        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ {
+            expires 1y;
+            add_header Cache-Control "public, max-age=31536000, immutable" always;
+            add_header X-Content-Type-Options nosniff always;
+            try_files $uri =404;
+        }
+
+        # SPA 路由回退
+        location / {
+            try_files $uri $uri/ /index.html;
+            add_header X-Frame-Options "SAMEORIGIN" always;
+            add_header X-Content-Type-Options "nosniff" always;
+            add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+        }
+    }
 }