Bläddra i källkod

feat(dakehu): 隔离 software_code 为 buff-xp-steam-qg 并完善卡密管理

大客户客户端强制使用 buff-xp-steam-qg;管理端补充卡密发放/注销/特殊关闭等字段与迁移,并同步 SteamQG 便携构建产物。

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 månad sedan
förälder
incheckning
540a3fc552
46 ändrade filer med 1823 tillägg och 130 borttagningar
  1. 1 1
      admin/README.md
  2. 1 1
      admin/index.html
  3. 118 0
      admin/nginx.production.conf
  4. 1 1
      admin/package.json
  5. BIN
      admin/public/logo.png
  6. 8 0
      admin/src/api/admin.ts
  7. 2 2
      admin/src/components/Layout.tsx
  8. 192 11
      admin/src/components/ResizableTable.tsx
  9. 58 0
      admin/src/index.css
  10. 0 1
      admin/src/pages/Announcements/index.tsx
  11. 572 62
      admin/src/pages/Cards/index.tsx
  12. 18 0
      admin/src/pages/Dashboard/index.tsx
  13. 1 1
      admin/src/pages/Keys/index.tsx
  14. 2 2
      admin/src/pages/Login/index.css
  15. 2 2
      admin/src/pages/Login/index.tsx
  16. 1 1
      admin/src/pages/Users/index.tsx
  17. 13 1
      backend-admin/README.md
  18. 1 1
      backend-admin/app/__init__.py
  19. 546 11
      backend-admin/app/api/admin/cards.py
  20. 23 0
      backend-admin/app/api/admin/dashboard.py
  21. 2 2
      backend-admin/app/config.py
  22. 3 3
      backend-admin/app/main.py
  23. 13 1
      backend-admin/app/models/card.py
  24. 13 0
      backend-admin/app/models/card_his.py
  25. 13 0
      backend-admin/app/utils/history.py
  26. 6 0
      backend-admin/migrations/20260411_add_cards_is_gift.sql
  27. 6 0
      backend-admin/migrations/20260411_add_cards_remark.sql
  28. 9 0
      backend-admin/migrations/20260415_add_cards_is_extracted.sql
  29. 18 0
      backend-admin/migrations/20260423_add_cards_issue_fields.sql
  30. 18 0
      backend-admin/migrations/20260423_add_cards_revoke_fields.sql
  31. 6 0
      backend-admin/migrations/20260928_add_cards_special_close.sql
  32. 16 0
      backend-admin/migrations/20260929_add_software_buff_xp_steam_qg.sql
  33. 2 2
      backend-admin/requirements.txt
  34. 21 8
      backend-admin/start.sh
  35. 71 0
      backend-admin/start_prod.sh
  36. 16 0
      backend-client/migrations/20260929_add_software_buff_xp_steam_qg.sql
  37. 3 3
      client-tauri/README.md
  38. BIN
      client-tauri/SteamQG.exe
  39. BIN
      client-tauri/SteamQG_Portable.zip
  40. BIN
      client-tauri/Steam求购.exe
  41. 1 1
      client-tauri/config.example.json
  42. 1 1
      client-tauri/default-config.json
  43. 1 1
      client-tauri/src-tauri/src/api_client.rs
  44. 12 1
      client-tauri/src-tauri/src/config.rs
  45. 5 4
      client-tauri/src/views/BuffSelectionView.vue
  46. 7 5
      client-tauri/src/views/SettingsView.vue

+ 1 - 1
admin/README.md

@@ -1,4 +1,4 @@
-# Steam求购管理端
+# 游铛铛管理端
 
 
 ## 技术栈
 ## 技术栈
 
 

+ 1 - 1
admin/index.html

@@ -4,7 +4,7 @@
     <meta charset="UTF-8" />
     <meta charset="UTF-8" />
     <link rel="icon" type="image/svg+xml" href="/vite.svg" />
     <link rel="icon" type="image/svg+xml" href="/vite.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Steam求购管理端</title>
+    <title>游铛铛管理端</title>
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

+ 118 - 0
admin/nginx.production.conf

@@ -0,0 +1,118 @@
+# =============================================================================
+# 游铛铛 admin 前端 — 生产环境 Nginx 示例
+# 部署机:125.122.32.114
+#
+# 用法 A(推荐,与 Debian/Ubuntu 默认结构一致):
+#   只取下方 server { ... } 块,保存为
+#   /etc/nginx/sites-available/youdangdang-admin
+#   ln -sf /etc/nginx/sites-available/youdangdang-admin /etc/nginx/sites-enabled/
+#   主 nginx.conf 需保留 http { include sites-enabled/*; }
+#
+# 用法 B:整机仅此站点时,可将本文件作为 /etc/nginx/nginx.conf 使用(已含 events/http)。
+#
+# 前置条件:
+#   1. 将 npm run build 生成的 dist 放到 root 指向的目录(默认 /var/www/youdangdang-admin/dist)
+#   2. 本机已启动 backend-admin(uvicorn 监听 127.0.0.1:9999 或 0.0.0.0:9999)
+#   3. admin 打包时 VITE_API_BASE_URL 为空(同源,仅请求 /admin/...,无跨域)
+# =============================================================================
+
+user www-data;
+worker_processes auto;
+error_log /var/log/nginx/error.log warn;
+pid /run/nginx.pid;
+
+events {
+    worker_connections 1024;
+}
+
+http {
+    include /etc/nginx/mime.types;
+    default_type application/octet-stream;
+    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+                    '$status $body_bytes_sent "$http_referer" '
+                    '"$http_user_agent" "$http_x_forwarded_for"';
+    access_log /var/log/nginx/access.log main;
+    sendfile on;
+    tcp_nopush on;
+    keepalive_timeout 65;
+    client_max_body_size 50m;
+
+    gzip on;
+    gzip_vary on;
+    gzip_min_length 1024;
+    gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
+
+    # 若与本机其它站点冲突,可改为 include /etc/nginx/conf.d/*.conf; 并只放 server 块到单独文件
+    server {
+        listen 80;
+        listen [::]:80;
+        server_name 125.122.32.114;
+
+        # 修改为服务器上 admin/dist 的实际路径
+        root /var/www/youdangdang-admin/dist;
+        index index.html;
+
+        # ---------- 管理端 API:同域反代,浏览器无跨域 ----------
+        # 请求路径保持不变转发到 FastAPI(/admin/...)
+        location /admin/ {
+            proxy_pass http://127.0.0.1:9999;
+            proxy_http_version 1.1;
+            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_connect_timeout 60s;
+            proxy_send_timeout 60s;
+            proxy_read_timeout 60s;
+            # 长连接可选(SSE/WebSocket 时按需打开)
+            # proxy_set_header Connection "";
+        }
+
+        # 无尾部斜杠的 /admin 可选重定向(与前端路由一致即可)
+        location = /admin {
+            return 301 /admin/;
+        }
+
+        # ---------- 后端健康检查、Swagger(按需保留;不需要可注释) ----------
+        location = /health {
+            proxy_pass http://127.0.0.1:9999/health;
+            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;
+        }
+
+        location /docs {
+            proxy_pass http://127.0.0.1:9999;
+            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;
+        }
+
+        location /redoc {
+            proxy_pass http://127.0.0.1:9999;
+            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;
+        }
+
+        location = /openapi.json {
+            proxy_pass http://127.0.0.1:9999/openapi.json;
+            proxy_set_header Host $host;
+        }
+
+        # ---------- SPA:前端路由 ----------
+        location / {
+            try_files $uri $uri/ /index.html;
+        }
+
+        # 静态资源缓存(可选)
+        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
+            expires 7d;
+            add_header Cache-Control "public, immutable";
+            try_files $uri =404;
+        }
+    }
+}

+ 1 - 1
admin/package.json

@@ -1,7 +1,7 @@
 {
 {
   "name": "youdangdang-admin",
   "name": "youdangdang-admin",
   "version": "1.0.0",
   "version": "1.0.0",
-  "description": "Steam求购管理端",
+  "description": "游铛铛管理端",
   "type": "module",
   "type": "module",
   "scripts": {
   "scripts": {
     "dev": "vite",
     "dev": "vite",

BIN
admin/public/logo.png


+ 8 - 0
admin/src/api/admin.ts

@@ -35,6 +35,14 @@ export const cardApi = {
   generate: (data: any) => apiClient.post('/admin/cards/generate', data),
   generate: (data: any) => apiClient.post('/admin/cards/generate', data),
   getList: (params?: any) => apiClient.get('/admin/cards', { params }),
   getList: (params?: any) => apiClient.get('/admin/cards', { params }),
   getDetail: (id: number) => apiClient.get(`/admin/cards/${id}`),
   getDetail: (id: number) => apiClient.get(`/admin/cards/${id}`),
+  extract: (id: number) => apiClient.put(`/admin/cards/${id}/extract`),
+  issue: (id: number, data: any) => apiClient.put(`/admin/cards/${id}/issue`, data),
+  batchExtract: (ids: number[]) => apiClient.post('/admin/cards/extract', { ids }),
+  batchIssue: (data: any) => apiClient.post('/admin/cards/issue', data),
+  revoke: (id: number, data?: any) => apiClient.put(`/admin/cards/${id}/revoke`, data || {}),
+  batchRevoke: (data: any) => apiClient.post('/admin/cards/revoke', data),
+  batchSpecialClose: (ids: number[], special_close: 0 | 1) =>
+    apiClient.post('/admin/cards/special-close', { ids, special_close }),
   getActivateInfo: (id: number) =>
   getActivateInfo: (id: number) =>
     apiClient.get(`/admin/cards/${id}/activate`),
     apiClient.get(`/admin/cards/${id}/activate`),
   exportExcel: (params?: any) =>
   exportExcel: (params?: any) =>

+ 2 - 2
admin/src/components/Layout.tsx

@@ -114,14 +114,14 @@ export default function Layout() {
         <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
         <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
           <img
           <img
             src="/logo.png"
             src="/logo.png"
-            alt="Steam求购"
+            alt="游铛铛"
             style={{
             style={{
               height: '48px',
               height: '48px',
               width: 'auto',
               width: 'auto',
             }}
             }}
           />
           />
           <span style={{ fontSize: 18, fontWeight: 600, color: 'rgba(0, 0, 0, 0.85)' }}>
           <span style={{ fontSize: 18, fontWeight: 600, color: 'rgba(0, 0, 0, 0.85)' }}>
-            Steam求购管理端
+            游铛铛管理端
           </span>
           </span>
         </div>
         </div>
 
 

+ 192 - 11
admin/src/components/ResizableTable.tsx

@@ -1,8 +1,157 @@
-import React, { useState, useEffect } from 'react'
+import React, { useState, useEffect, useRef, useCallback } from 'react'
 import { Table, TableProps } from 'antd'
 import { Table, TableProps } from 'antd'
 import { Resizable } from 'react-resizable'
 import { Resizable } from 'react-resizable'
 import type { ColumnsType } from 'antd/es/table'
 import type { ColumnsType } from 'antd/es/table'
 
 
+const HORIZONTAL_SCROLLBAR_HEIGHT = 10
+
+function getTableScrollElement(wrapper: HTMLElement | null): HTMLElement | null {
+  if (!wrapper) return null
+  return (
+    (wrapper.querySelector('.ant-table-content') as HTMLElement | null) ||
+    (wrapper.querySelector('.ant-table-body') as HTMLElement | null)
+  )
+}
+
+function TableHorizontalScrollbar({
+  wrapperRef,
+  refreshKey,
+}: {
+  wrapperRef: React.RefObject<HTMLDivElement>
+  refreshKey: string
+}) {
+  const trackRef = useRef<HTMLDivElement>(null)
+  const scrollElRef = useRef<HTMLElement | null>(null)
+  const dragRef = useRef({ dragging: false, startX: 0, startScrollLeft: 0 })
+  const [metrics, setMetrics] = useState({ scrollWidth: 1, clientWidth: 1, scrollLeft: 0 })
+
+  const updateMetrics = useCallback(() => {
+    const el = scrollElRef.current
+    if (!el) return
+    setMetrics({
+      scrollWidth: el.scrollWidth,
+      clientWidth: el.clientWidth,
+      scrollLeft: el.scrollLeft,
+    })
+  }, [])
+
+  useEffect(() => {
+    const wrapper = wrapperRef.current
+    if (!wrapper) return
+
+    let scrollEl: HTMLElement | null = null
+    let observer: MutationObserver | null = null
+    let resizeObserver: ResizeObserver | null = null
+
+    const bindScrollElement = () => {
+      const nextEl = getTableScrollElement(wrapper)
+      if (!nextEl || nextEl === scrollEl) return !!nextEl
+
+      if (scrollEl) {
+        scrollEl.removeEventListener('scroll', updateMetrics)
+      }
+
+      scrollEl = nextEl
+      scrollElRef.current = nextEl
+      scrollEl.addEventListener('scroll', updateMetrics, { passive: true })
+      updateMetrics()
+      return true
+    }
+
+    if (!bindScrollElement()) {
+      observer = new MutationObserver(() => {
+        if (bindScrollElement()) {
+          observer?.disconnect()
+        }
+      })
+      observer.observe(wrapper, { childList: true, subtree: true })
+    }
+
+    resizeObserver = new ResizeObserver(updateMetrics)
+    resizeObserver.observe(wrapper)
+    if (scrollElRef.current) {
+      resizeObserver.observe(scrollElRef.current)
+    }
+
+    return () => {
+      observer?.disconnect()
+      resizeObserver?.disconnect()
+      scrollElRef.current?.removeEventListener('scroll', updateMetrics)
+      scrollElRef.current = null
+    }
+  }, [wrapperRef, updateMetrics, refreshKey])
+
+  const maxScroll = Math.max(metrics.scrollWidth - metrics.clientWidth, 0)
+  const thumbRatio = metrics.clientWidth / Math.max(metrics.scrollWidth, 1)
+  const thumbWidthPercent = maxScroll > 0 ? Math.max(thumbRatio * 100, 8) : 100
+  const thumbLeftPercent =
+    maxScroll > 0 ? (metrics.scrollLeft / maxScroll) * (100 - thumbWidthPercent) : 0
+
+  const handleTrackClick = (event: React.MouseEvent<HTMLDivElement>) => {
+    const scrollEl = scrollElRef.current
+    const track = trackRef.current
+    if (!scrollEl || !track || maxScroll <= 0) return
+
+    const rect = track.getBoundingClientRect()
+    const ratio = (event.clientX - rect.left) / rect.width
+    scrollEl.scrollLeft = ratio * maxScroll
+  }
+
+  const handleThumbMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
+    event.stopPropagation()
+    const scrollEl = scrollElRef.current
+    const track = trackRef.current
+    if (!scrollEl || !track || maxScroll <= 0) return
+
+    dragRef.current = {
+      dragging: true,
+      startX: event.clientX,
+      startScrollLeft: scrollEl.scrollLeft,
+    }
+
+    const onMouseMove = (moveEvent: MouseEvent) => {
+      if (!dragRef.current.dragging) return
+      const trackWidth = track.clientWidth
+      const thumbTravel = trackWidth * (1 - thumbRatio)
+      if (thumbTravel <= 0) return
+
+      const delta = moveEvent.clientX - dragRef.current.startX
+      const scrollDelta = (delta / thumbTravel) * maxScroll
+      scrollEl.scrollLeft = Math.min(
+        maxScroll,
+        Math.max(0, dragRef.current.startScrollLeft + scrollDelta),
+      )
+    }
+
+    const onMouseUp = () => {
+      dragRef.current.dragging = false
+      document.removeEventListener('mousemove', onMouseMove)
+      document.removeEventListener('mouseup', onMouseUp)
+    }
+
+    document.addEventListener('mousemove', onMouseMove)
+    document.addEventListener('mouseup', onMouseUp)
+  }
+
+  return (
+    <div
+      ref={trackRef}
+      className="resizable-table-h-scrollbar"
+      style={{ height: HORIZONTAL_SCROLLBAR_HEIGHT }}
+      onClick={handleTrackClick}
+    >
+      <div
+        className="resizable-table-h-scrollbar-thumb"
+        style={{
+          width: `${thumbWidthPercent}%`,
+          marginLeft: `${thumbLeftPercent}%`,
+        }}
+        onMouseDown={handleThumbMouseDown}
+      />
+    </div>
+  )
+}
+
 // 拖拽手柄组件
 // 拖拽手柄组件
 const ResizeHandle = React.forwardRef<any, any>((props, ref) => {
 const ResizeHandle = React.forwardRef<any, any>((props, ref) => {
   const { handleAxis, ...restProps } = props
   const { handleAxis, ...restProps } = props
@@ -48,11 +197,14 @@ const ResizableTitle = (props: any) => {
 interface ResizableTableProps<T> extends TableProps<T> {
 interface ResizableTableProps<T> extends TableProps<T> {
   columns: ColumnsType<T>
   columns: ColumnsType<T>
   actionColumnKey?: string // 操作列的 key,用于固定到右侧
   actionColumnKey?: string // 操作列的 key,用于固定到右侧
+  /** 自适应容器宽度,不启用横向滚动 */
+  fitViewport?: boolean
 }
 }
 
 
 export default function ResizableTable<T extends object>(props: ResizableTableProps<T>) {
 export default function ResizableTable<T extends object>(props: ResizableTableProps<T>) {
-  const { columns: initialColumns, actionColumnKey, ...tableProps } = props
+  const { columns: initialColumns, actionColumnKey, fitViewport = false, ...tableProps } = props
   const [columns, setColumns] = useState<ColumnsType<T>>(initialColumns)
   const [columns, setColumns] = useState<ColumnsType<T>>(initialColumns)
+  const wrapperRef = useRef<HTMLDivElement>(null)
 
 
   useEffect(() => {
   useEffect(() => {
     setColumns(initialColumns)
     setColumns(initialColumns)
@@ -74,8 +226,12 @@ export default function ResizableTable<T extends object>(props: ResizableTablePr
     if (actionColumnKey && col.key === actionColumnKey) {
     if (actionColumnKey && col.key === actionColumnKey) {
       return {
       return {
         ...col,
         ...col,
-        fixed: 'right' as const,
-        width: col.width || 200, // 确保操作列有默认宽度
+        ...(fitViewport
+          ? {}
+          : {
+              fixed: 'right' as const,
+              width: col.width || 200,
+            }),
       }
       }
     }
     }
 
 
@@ -90,25 +246,50 @@ export default function ResizableTable<T extends object>(props: ResizableTablePr
   })
   })
 
 
   // 计算表格最小宽度,用于启用横向滚动
   // 计算表格最小宽度,用于启用横向滚动
-  const minWidth = resizableColumns.reduce((total, col) => {
-    return total + (col.width as number || 100)
+  const columnsWidth = resizableColumns.reduce((total, col) => {
+    return total + ((col.width as number) || 100)
   }, 0)
   }, 0)
+  const selectionWidth = tableProps.rowSelection ? 48 : 0
+  const minWidth = columnsWidth + selectionWidth
+  const scrollX =
+    typeof tableProps.scroll?.x === 'number' || typeof tableProps.scroll?.x === 'string'
+      ? tableProps.scroll.x
+      : Math.max(minWidth, 800)
+
+  const scrollRefreshKey = [
+    scrollX,
+    tableProps.loading,
+    Array.isArray(tableProps.dataSource) ? tableProps.dataSource.length : 0,
+  ].join('-')
+
+  const tableScroll = fitViewport
+    ? tableProps.scroll?.y
+      ? { y: tableProps.scroll.y }
+      : undefined
+    : {
+        ...tableProps.scroll,
+        x: scrollX,
+      }
 
 
   return (
   return (
-    <div style={{ overflow: 'auto' }}>
+    <div
+      className={`resizable-table-wrapper${fitViewport ? ' resizable-table-fit-viewport' : ''}`}
+      ref={wrapperRef}
+    >
       <Table
       <Table
         {...tableProps}
         {...tableProps}
+        tableLayout={fitViewport ? 'auto' : tableProps.tableLayout}
         columns={resizableColumns}
         columns={resizableColumns}
         components={{
         components={{
           header: {
           header: {
             cell: ResizableTitle,
             cell: ResizableTitle,
           },
           },
         }}
         }}
-        scroll={{
-          x: minWidth > 800 ? minWidth : 800, // 当总宽度超过 800px 时启用横向滚动
-          y: tableProps.scroll?.y,
-        }}
+        scroll={tableScroll}
       />
       />
+      {!fitViewport && (
+        <TableHorizontalScrollbar wrapperRef={wrapperRef} refreshKey={scrollRefreshKey} />
+      )}
     </div>
     </div>
   )
   )
 }
 }

+ 58 - 0
admin/src/index.css

@@ -98,6 +98,64 @@ body {
   background: #f5f5f5;
   background: #f5f5f5;
 }
 }
 
 
+/* 可调整列宽表格:始终显示横向滚动条 */
+.resizable-table-wrapper {
+  width: 100%;
+}
+
+/* 隐藏表格内部原生横向滚动条,改用下方自定义滚动条 */
+.resizable-table-wrapper .ant-table-body,
+.resizable-table-wrapper .ant-table-content {
+  overflow-x: scroll !important;
+  scrollbar-width: none;
+  -ms-overflow-style: none;
+}
+
+.resizable-table-wrapper .ant-table-body::-webkit-scrollbar,
+.resizable-table-wrapper .ant-table-content::-webkit-scrollbar {
+  display: none;
+}
+
+.resizable-table-h-scrollbar {
+  width: 100%;
+  margin-top: 6px;
+  background: rgba(0, 0, 0, 0.06);
+  border-radius: 5px;
+  cursor: pointer;
+  flex-shrink: 0;
+}
+
+.resizable-table-h-scrollbar-thumb {
+  height: 100%;
+  min-width: 40px;
+  background: rgba(0, 0, 0, 0.28);
+  border-radius: 5px;
+  cursor: grab;
+}
+
+.resizable-table-h-scrollbar-thumb:hover {
+  background: rgba(0, 0, 0, 0.38);
+}
+
+.resizable-table-h-scrollbar-thumb:active {
+  cursor: grabbing;
+  background: rgba(0, 0, 0, 0.48);
+}
+
+/* 自适应容器宽度:不出现横向滚动 */
+.resizable-table-fit-viewport .ant-table-body,
+.resizable-table-fit-viewport .ant-table-content {
+  overflow-x: hidden !important;
+}
+
+.resizable-table-fit-viewport .ant-table {
+  width: 100%;
+}
+
+.resizable-table-fit-viewport .ant-table-cell {
+  white-space: nowrap;
+}
+
 /* 输入框样式优化 */
 /* 输入框样式优化 */
 .ant-input,
 .ant-input,
 .ant-input-password,
 .ant-input-password,

+ 0 - 1
admin/src/pages/Announcements/index.tsx

@@ -13,7 +13,6 @@ import {
   Tag,
   Tag,
   Card,
   Card,
 } from 'antd'
 } from 'antd'
-import { EyeOutlined } from '@ant-design/icons'
 import { announcementApi } from '../../api/admin'
 import { announcementApi } from '../../api/admin'
 import type { ColumnsType } from 'antd/es/table'
 import type { ColumnsType } from 'antd/es/table'
 import dayjs from 'dayjs'
 import dayjs from 'dayjs'

+ 572 - 62
admin/src/pages/Cards/index.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, type Key } from 'react'
 import {
 import {
   Button,
   Button,
   message,
   message,
@@ -7,12 +7,14 @@ import {
   Select,
   Select,
   InputNumber,
   InputNumber,
   Input,
   Input,
+  Tooltip,
   Space,
   Space,
   Tag,
   Tag,
   Descriptions,
   Descriptions,
   Card,
   Card,
+  Radio,
 } from 'antd'
 } from 'antd'
-import { SearchOutlined, EyeOutlined, DownloadOutlined } from '@ant-design/icons'
+import { SearchOutlined } from '@ant-design/icons'
 import { cardApi, softwareApi } from '../../api/admin'
 import { cardApi, softwareApi } from '../../api/admin'
 import dayjs from 'dayjs'
 import dayjs from 'dayjs'
 import { formatDate } from '../../utils/formatDate'
 import { formatDate } from '../../utils/formatDate'
@@ -27,6 +29,23 @@ interface Card {
   days: number
   days: number
   discount: number
   discount: number
   status: number
   status: number
+  /** 0 收费 1 赠送 */
+  is_gift: number
+  /** 0 未提取 1 已提取 */
+  is_extracted: number
+  /** 0 关闭 1 打开 */
+  special_close: number
+  /** 0 未注销 1 已注销 */
+  is_revoked?: number
+  revoked_at?: string | null
+  revoked_reason?: string | null
+  revoked_admin_id?: number | null
+  issue_price?: number | null
+  issued_to?: string | null
+  issue_remark?: string | null
+  issued_at?: string | null
+  issued_admin_id?: number | null
+  remark?: string | null
   user_id: number | null
   user_id: number | null
   username: string | null
   username: string | null
   activated_at: string | null
   activated_at: string | null
@@ -41,56 +60,148 @@ export default function Cards() {
   const [softwareList, setSoftwareList] = useState<any[]>([])
   const [softwareList, setSoftwareList] = useState<any[]>([])
   const [modalVisible, setModalVisible] = useState(false)
   const [modalVisible, setModalVisible] = useState(false)
   const [form] = Form.useForm()
   const [form] = Form.useForm()
+  const [issueModalVisible, setIssueModalVisible] = useState(false)
+  const [issueSubmitting, setIssueSubmitting] = useState(false)
+  const [issueForm] = Form.useForm()
+  const [issueMode, setIssueMode] = useState<'single' | 'batch'>('single')
+  const [issuingCard, setIssuingCard] = useState<Card | null>(null)
   const [keyword, setKeyword] = useState('')
   const [keyword, setKeyword] = useState('')
   const [softwareFilter, setSoftwareFilter] = useState<string | undefined>(undefined)
   const [softwareFilter, setSoftwareFilter] = useState<string | undefined>(undefined)
   const [statusFilter, setStatusFilter] = useState<number | undefined>(undefined)
   const [statusFilter, setStatusFilter] = useState<number | undefined>(undefined)
+  const [isGiftFilter, setIsGiftFilter] = useState<number | undefined>(undefined)
+  const [isExtractedFilter, setIsExtractedFilter] = useState<number | undefined>(undefined)
+  const [specialCloseFilter, setSpecialCloseFilter] = useState<number | undefined>(undefined)
+  const [isRevokedFilter, setIsRevokedFilter] = useState<number | undefined>(undefined)
+  const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([])
   const [detailVisible, setDetailVisible] = useState(false)
   const [detailVisible, setDetailVisible] = useState(false)
   const [detailData, setDetailData] = useState<any>(null)
   const [detailData, setDetailData] = useState<any>(null)
   const [detailLoading, setDetailLoading] = useState(false)
   const [detailLoading, setDetailLoading] = useState(false)
 
 
+  const [revokeModalVisible, setRevokeModalVisible] = useState(false)
+  const [revokeSubmitting, setRevokeSubmitting] = useState(false)
+  const [revokeForm] = Form.useForm()
+  const [revokeMode, setRevokeMode] = useState<'single' | 'batch'>('single')
+  const [revokingCard, setRevokingCard] = useState<Card | null>(null)
+
+  const renderEllipsis = (text: string | null | undefined) => {
+    const value = text?.trim()
+    if (!value) return '-'
+    return (
+      <Tooltip title={value}>
+        <span>{value}</span>
+      </Tooltip>
+    )
+  }
+
+  const renderDateCell = (text: string | null | undefined) => {
+    if (!text) return '-'
+    const full = formatDate(text)
+    const short = formatDate(text, 'MM-DD HH:mm')
+    return (
+      <Tooltip title={full}>
+        <span>{short}</span>
+      </Tooltip>
+    )
+  }
+
   const columns: ColumnsType<Card> = [
   const columns: ColumnsType<Card> = [
     {
     {
-      title: 'ID',
-      dataIndex: 'id',
-      key: 'id',
-      width: 80,
+      title: '序号',
+      key: 'index',
+      width: 48,
+      align: 'center',
+      render: (_: unknown, __: Card, index: number) =>
+        (pagination.current - 1) * pagination.pageSize + index + 1,
     },
     },
     {
     {
       title: '卡密',
       title: '卡密',
       dataIndex: 'card_code',
       dataIndex: 'card_code',
       key: 'card_code',
       key: 'card_code',
-      width: 160,
-    },
-    {
-      title: '软件特征码',
-      dataIndex: 'software_code',
-      key: 'software_code',
-      width: 140,
+      width: 118,
+      ellipsis: { showTitle: false },
+      render: (text: string) => renderEllipsis(text),
     },
     },
     {
     {
       title: '软件名称',
       title: '软件名称',
       dataIndex: 'software_name',
       dataIndex: 'software_name',
       key: 'software_name',
       key: 'software_name',
-      width: 120,
+      width: 88,
+      ellipsis: { showTitle: false },
+      render: (text: string) => renderEllipsis(text),
     },
     },
     {
     {
       title: '天数',
       title: '天数',
       dataIndex: 'days',
       dataIndex: 'days',
       key: 'days',
       key: 'days',
-      width: 80,
+      width: 48,
+      align: 'center',
     },
     },
     {
     {
-      title: '折扣',
-      dataIndex: 'discount',
-      key: 'discount',
-      width: 80,
-      render: (discount) => `${(discount * 100).toFixed(0)}%`,
+      title: '类型',
+      dataIndex: 'is_gift',
+      key: 'is_gift',
+      width: 60,
+      align: 'center',
+      render: (v: number) =>
+        v === 1 ? <Tag color="purple">赠送</Tag> : <Tag color="blue">收费</Tag>,
+    },
+    {
+      title: '提取状态',
+      dataIndex: 'is_extracted',
+      key: 'is_extracted',
+      width: 68,
+      align: 'center',
+      render: (v: number) =>
+        v === 1 ? <Tag color="green">已提取</Tag> : <Tag>未提取</Tag>,
+    },
+    {
+      title: '特殊关闭',
+      dataIndex: 'special_close',
+      key: 'special_close',
+      width: 72,
+      align: 'center',
+      render: (v: number) =>
+        v === 1 ? <Tag color="orange">打开</Tag> : <Tag>关闭</Tag>,
+    },
+    {
+      title: '注销状态',
+      dataIndex: 'is_revoked',
+      key: 'is_revoked',
+      width: 68,
+      align: 'center',
+      render: (v: number | undefined) =>
+        v === 1 ? <Tag color="red">已注销</Tag> : <Tag>未注销</Tag>,
+    },
+    {
+      title: '发放价格',
+      dataIndex: 'issue_price',
+      key: 'issue_price',
+      width: 64,
+      align: 'right',
+      render: (v: number | null | undefined) => (v === null || v === undefined ? '-' : v),
+    },
+    {
+      title: '发放备注',
+      dataIndex: 'issue_remark',
+      key: 'issue_remark',
+      width: 96,
+      ellipsis: { showTitle: false },
+      render: (text: string | null | undefined) => renderEllipsis(text),
+    },
+    {
+      title: '发放时间',
+      dataIndex: 'issued_at',
+      key: 'issued_at',
+      width: 104,
+      ellipsis: { showTitle: false },
+      render: (text: string | null | undefined) => renderDateCell(text),
     },
     },
     {
     {
       title: '状态',
       title: '状态',
       dataIndex: 'status',
       dataIndex: 'status',
       key: 'status',
       key: 'status',
-      width: 100,
+      width: 64,
+      align: 'center',
       render: (status) => {
       render: (status) => {
         const statusMap: Record<number, { text: string; color: string }> = {
         const statusMap: Record<number, { text: string; color: string }> = {
           0: { text: '未激活', color: 'default' },
           0: { text: '未激活', color: 'default' },
@@ -101,60 +212,63 @@ export default function Cards() {
         return <Tag color={statusInfo.color}>{statusInfo.text}</Tag>
         return <Tag color={statusInfo.color}>{statusInfo.text}</Tag>
       },
       },
     },
     },
-    {
-      title: '激活用户ID',
-      dataIndex: 'user_id',
-      key: 'user_id',
-      width: 120,
-      render: (text: number) => text || '-',
-    },
     {
     {
       title: '用户名称',
       title: '用户名称',
       dataIndex: 'username',
       dataIndex: 'username',
       key: 'username',
       key: 'username',
-      width: 120,
-      render: (text: string) => text || '-',
+      width: 80,
+      ellipsis: { showTitle: false },
+      render: (text: string) => renderEllipsis(text),
     },
     },
     {
     {
       title: '激活时间',
       title: '激活时间',
       dataIndex: 'activated_at',
       dataIndex: 'activated_at',
       key: 'activated_at',
       key: 'activated_at',
-      width: 160,
-      render: (text: string) => formatDate(text),
+      width: 104,
+      ellipsis: { showTitle: false },
+      render: (text: string) => renderDateCell(text),
     },
     },
     {
     {
       title: '到期时间',
       title: '到期时间',
       dataIndex: 'expire_time',
       dataIndex: 'expire_time',
       key: 'expire_time',
       key: 'expire_time',
-      width: 160,
-      render: (text: string) => formatDate(text),
-    },
-    {
-      title: '创建时间',
-      dataIndex: 'created_at',
-      key: 'created_at',
-      width: 160,
-      render: (text: string) => formatDate(text),
-    },
-    {
-      title: '更新时间',
-      dataIndex: 'updated_at',
-      key: 'updated_at',
-      width: 160,
-      render: (text: string) => (text ? formatDate(text) : '-'),
+      width: 104,
+      ellipsis: { showTitle: false },
+      render: (text: string) => renderDateCell(text),
     },
     },
     {
     {
       title: '操作',
       title: '操作',
       key: 'action',
       key: 'action',
-      width: 100,
+      width: 168,
       render: (_, record: Card) => (
       render: (_, record: Card) => (
-        <Button
-          type="link"
-          size="small"
-          onClick={() => handleViewDetail(record.id)}
-        >
-          详情
-        </Button>
+        <Space size={0}>
+          <Button
+            type="link"
+            size="small"
+            onClick={() => handleViewDetail(record.id)}
+          >
+            详情
+          </Button>
+          <Button type="link" size="small" onClick={() => openIssueModalSingle(record)}>
+            发放
+          </Button>
+          <Button
+            type="link"
+            size="small"
+            onClick={() => handleExtract(record)}
+          >
+            提取
+          </Button>
+          <Button
+            type="link"
+            size="small"
+            danger
+            disabled={record.is_revoked === 1 || record.status !== 0}
+            onClick={() => openRevokeModalSingle(record)}
+          >
+            注销
+          </Button>
+        </Space>
       ),
       ),
     },
     },
   ]
   ]
@@ -181,10 +295,23 @@ export default function Cards() {
       if (statusFilter !== undefined) {
       if (statusFilter !== undefined) {
         params.status = statusFilter
         params.status = statusFilter
       }
       }
+      if (isGiftFilter !== undefined) {
+        params.is_gift = isGiftFilter
+      }
+      if (isExtractedFilter !== undefined) {
+        params.is_extracted = isExtractedFilter
+      }
+      if (specialCloseFilter !== undefined) {
+        params.special_close = specialCloseFilter
+      }
+      if (isRevokedFilter !== undefined) {
+        params.is_revoked = isRevokedFilter
+      }
 
 
       const response = await cardApi.getList(params)
       const response = await cardApi.getList(params)
       if (response.code === 200) {
       if (response.code === 200) {
         setData(response.data || [])
         setData(response.data || [])
+        setSelectedRowKeys([])
         setPagination({
         setPagination({
           ...pagination,
           ...pagination,
           total: response.total || 0,
           total: response.total || 0,
@@ -211,7 +338,16 @@ export default function Cards() {
   useEffect(() => {
   useEffect(() => {
     fetchData()
     fetchData()
     fetchSoftwareList()
     fetchSoftwareList()
-  }, [pagination.current, pagination.pageSize, softwareFilter, statusFilter])
+  }, [
+    pagination.current,
+    pagination.pageSize,
+    softwareFilter,
+    statusFilter,
+    isGiftFilter,
+    isExtractedFilter,
+    specialCloseFilter,
+    isRevokedFilter,
+  ])
 
 
   const handleSearch = () => {
   const handleSearch = () => {
     setPagination({ ...pagination, current: 1 })
     setPagination({ ...pagination, current: 1 })
@@ -247,6 +383,189 @@ export default function Cards() {
     }
     }
   }
   }
 
 
+  const copyToClipboard = async (text: string) => {
+    if (!text) return
+    if (navigator.clipboard?.writeText) {
+      await navigator.clipboard.writeText(text)
+      return
+    }
+    const textarea = document.createElement('textarea')
+    textarea.value = text
+    textarea.style.position = 'fixed'
+    textarea.style.opacity = '0'
+    document.body.appendChild(textarea)
+    textarea.focus()
+    textarea.select()
+    document.execCommand('copy')
+    document.body.removeChild(textarea)
+  }
+
+  const handleExtract = async (record: Card) => {
+    try {
+      const response: any = await cardApi.extract(record.id)
+      if (response.code !== 200) {
+        message.error(response.msg || '提取失败')
+        return
+      }
+      const cardCode = response.data?.card_code || record.card_code
+      await copyToClipboard(cardCode)
+      message.success('提取成功,卡密已复制到剪贴板')
+      fetchData()
+    } catch (error) {
+      message.error('提取失败')
+    }
+  }
+
+  const handleBatchExtract = async () => {
+    if (selectedRowKeys.length === 0) {
+      message.warning('请先勾选需要提取的卡密')
+      return
+    }
+    try {
+      const ids = selectedRowKeys.map((id) => Number(id))
+      const response: any = await cardApi.batchExtract(ids)
+      if (response.code !== 200) {
+        message.error(response.msg || '批量提取失败')
+        return
+      }
+      const cards: string[] = response.data?.cards || []
+      await copyToClipboard(cards.join('\n'))
+      message.success(`批量提取成功,已复制 ${cards.length} 个卡密`)
+      fetchData()
+    } catch (error) {
+      message.error('批量提取失败')
+    }
+  }
+
+  const handleBatchSpecialClose = async (specialClose: 0 | 1) => {
+    if (selectedRowKeys.length === 0) {
+      message.warning(`请先勾选需要${specialClose === 1 ? '打开' : '关闭'}特殊关闭的卡密`)
+      return
+    }
+    try {
+      const ids = selectedRowKeys.map((id) => Number(id))
+      const response: any = await cardApi.batchSpecialClose(ids, specialClose)
+      if (response.code !== 200) {
+        message.error(response.msg || '批量设置特殊关闭失败')
+        return
+      }
+      message.success(
+        `批量特殊关闭${specialClose === 1 ? '打开' : '关闭'}成功:${response.data?.count ?? ids.length} 张`
+      )
+      fetchData()
+    } catch (error) {
+      message.error('批量设置特殊关闭失败')
+    }
+  }
+
+  const openIssueModalSingle = (record: Card) => {
+    setIssueMode('single')
+    setIssuingCard(record)
+    issueForm.setFieldsValue({
+      issue_price: record.issue_price ?? 0,
+      issued_to: record.issued_to ?? '',
+      issue_remark: record.issue_remark ?? '',
+    })
+    setIssueModalVisible(true)
+  }
+
+  const openIssueModalBatch = () => {
+    if (selectedRowKeys.length === 0) {
+      message.warning('请先勾选需要发放的卡密')
+      return
+    }
+    setIssueMode('batch')
+    setIssuingCard(null)
+    issueForm.resetFields()
+    setIssueModalVisible(true)
+  }
+
+  const submitIssue = async () => {
+    try {
+      const values = await issueForm.validateFields()
+      setIssueSubmitting(true)
+      if (issueMode === 'single') {
+        if (!issuingCard) {
+          message.error('未选择要发放的卡密')
+          return
+        }
+        const resp: any = await cardApi.issue(issuingCard.id, values)
+        if (resp.code !== 200) {
+          message.error(resp.msg || '发放失败')
+          return
+        }
+        message.success('发放成功')
+      } else {
+        const ids = selectedRowKeys.map((id) => Number(id))
+        const resp: any = await cardApi.batchIssue({ ids, ...values })
+        if (resp.code !== 200) {
+          message.error(resp.msg || '批量发放失败')
+          return
+        }
+        message.success(`批量发放成功:${resp.data?.count ?? ids.length} 张`)
+      }
+      setIssueModalVisible(false)
+      issueForm.resetFields()
+      fetchData()
+    } catch (e) {
+      // 表单校验失败会走到这里,忽略即可
+    } finally {
+      setIssueSubmitting(false)
+    }
+  }
+
+  const openRevokeModalSingle = (record: Card) => {
+    setRevokeMode('single')
+    setRevokingCard(record)
+    revokeForm.resetFields()
+    setRevokeModalVisible(true)
+  }
+
+  const openRevokeModalBatch = () => {
+    if (selectedRowKeys.length === 0) {
+      message.warning('请先勾选需要注销的卡密')
+      return
+    }
+    setRevokeMode('batch')
+    setRevokingCard(null)
+    revokeForm.resetFields()
+    setRevokeModalVisible(true)
+  }
+
+  const submitRevoke = async () => {
+    try {
+      const values = await revokeForm.validateFields()
+      setRevokeSubmitting(true)
+      if (revokeMode === 'single') {
+        if (!revokingCard) {
+          message.error('未选择要注销的卡密')
+          return
+        }
+        const resp: any = await cardApi.revoke(revokingCard.id, values)
+        if (resp.code !== 200) {
+          message.error(resp.msg || '注销失败')
+          return
+        }
+        message.success('注销成功')
+      } else {
+        const ids = selectedRowKeys.map((id) => Number(id))
+        const resp: any = await cardApi.batchRevoke({ ids, ...values })
+        if (resp.code !== 200) {
+          message.error(resp.msg || '批量注销失败')
+          return
+        }
+        message.success(`批量注销成功:${resp.data?.count ?? ids.length} 张`)
+      }
+      setRevokeModalVisible(false)
+      revokeForm.resetFields()
+      fetchData()
+    } catch (e) {
+      // ignore form validation errors
+    } finally {
+      setRevokeSubmitting(false)
+    }
+  }
+
   const handleExport = async () => {
   const handleExport = async () => {
     try {
     try {
       const params: any = {}
       const params: any = {}
@@ -259,6 +578,18 @@ export default function Cards() {
       if (statusFilter !== undefined) {
       if (statusFilter !== undefined) {
         params.status = statusFilter
         params.status = statusFilter
       }
       }
+      if (isGiftFilter !== undefined) {
+        params.is_gift = isGiftFilter
+      }
+      if (isExtractedFilter !== undefined) {
+        params.is_extracted = isExtractedFilter
+      }
+      if (specialCloseFilter !== undefined) {
+        params.special_close = specialCloseFilter
+      }
+      if (isRevokedFilter !== undefined) {
+        params.is_revoked = isRevokedFilter
+      }
 
 
       const responseAny: any = await cardApi.exportExcel(params)
       const responseAny: any = await cardApi.exportExcel(params)
 
 
@@ -361,7 +692,7 @@ export default function Cards() {
         >
         >
         <Space size="middle" wrap>
         <Space size="middle" wrap>
           <Input
           <Input
-            placeholder="搜索卡密代码"
+            placeholder="搜索卡密代码或备注"
             value={keyword}
             value={keyword}
             onChange={(e) => setKeyword(e.target.value)}
             onChange={(e) => setKeyword(e.target.value)}
             onPressEnter={handleSearch}
             onPressEnter={handleSearch}
@@ -392,6 +723,46 @@ export default function Cards() {
             <Select.Option value={1}>已激活</Select.Option>
             <Select.Option value={1}>已激活</Select.Option>
             <Select.Option value={2}>已过期</Select.Option>
             <Select.Option value={2}>已过期</Select.Option>
           </Select>
           </Select>
+          <Select
+            placeholder="卡密类型"
+            value={isGiftFilter}
+            onChange={setIsGiftFilter}
+            allowClear
+            style={{ width: 120 }}
+          >
+            <Select.Option value={0}>收费</Select.Option>
+            <Select.Option value={1}>赠送</Select.Option>
+          </Select>
+          <Select
+            placeholder="提取状态"
+            value={isExtractedFilter}
+            onChange={setIsExtractedFilter}
+            allowClear
+            style={{ width: 120 }}
+          >
+            <Select.Option value={0}>未提取</Select.Option>
+            <Select.Option value={1}>已提取</Select.Option>
+          </Select>
+          <Select
+            placeholder="特殊关闭"
+            value={specialCloseFilter}
+            onChange={setSpecialCloseFilter}
+            allowClear
+            style={{ width: 120 }}
+          >
+            <Select.Option value={0}>关闭</Select.Option>
+            <Select.Option value={1}>打开</Select.Option>
+          </Select>
+          <Select
+            placeholder="注销状态"
+            value={isRevokedFilter}
+            onChange={setIsRevokedFilter}
+            allowClear
+            style={{ width: 120 }}
+          >
+            <Select.Option value={0}>未注销</Select.Option>
+            <Select.Option value={1}>已注销</Select.Option>
+          </Select>
           <Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
           <Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
             搜索
             搜索
           </Button>
           </Button>
@@ -400,6 +771,10 @@ export default function Cards() {
               setKeyword('')
               setKeyword('')
               setSoftwareFilter(undefined)
               setSoftwareFilter(undefined)
               setStatusFilter(undefined)
               setStatusFilter(undefined)
+              setIsGiftFilter(undefined)
+              setIsExtractedFilter(undefined)
+              setSpecialCloseFilter(undefined)
+              setIsRevokedFilter(undefined)
               setPagination({ ...pagination, current: 1 })
               setPagination({ ...pagination, current: 1 })
             }}
             }}
           >
           >
@@ -417,6 +792,27 @@ export default function Cards() {
             <Button type="primary" onClick={() => setModalVisible(true)}>
             <Button type="primary" onClick={() => setModalVisible(true)}>
               生成卡密
               生成卡密
             </Button>
             </Button>
+            <Button onClick={handleBatchExtract}>
+              批量提取
+            </Button>
+            <Button onClick={openIssueModalBatch}>批量发放</Button>
+            <Button
+              type="primary"
+              style={{ background: '#fa8c16', borderColor: '#fa8c16' }}
+              onClick={() => handleBatchSpecialClose(1)}
+            >
+              特殊关闭打开
+            </Button>
+            <Button
+              type="primary"
+              style={{ background: '#52c41a', borderColor: '#52c41a' }}
+              onClick={() => handleBatchSpecialClose(0)}
+            >
+              特殊关闭关闭
+            </Button>
+            <Button danger onClick={openRevokeModalBatch}>
+              批量注销
+            </Button>
             <Button onClick={handleExport}>
             <Button onClick={handleExport}>
               导出Excel
               导出Excel
             </Button>
             </Button>
@@ -428,7 +824,13 @@ export default function Cards() {
         dataSource={data}
         dataSource={data}
         loading={loading}
         loading={loading}
         rowKey="id"
         rowKey="id"
+        size="small"
+        fitViewport
         actionColumnKey="action"
         actionColumnKey="action"
+        rowSelection={{
+          selectedRowKeys,
+          onChange: (keys) => setSelectedRowKeys(keys),
+        }}
         pagination={{
         pagination={{
           current: pagination.current,
           current: pagination.current,
           pageSize: pagination.pageSize,
           pageSize: pagination.pageSize,
@@ -470,6 +872,30 @@ export default function Cards() {
               <Descriptions.Item label="折扣">
               <Descriptions.Item label="折扣">
                 {(detailData.discount * 100).toFixed(0)}%
                 {(detailData.discount * 100).toFixed(0)}%
               </Descriptions.Item>
               </Descriptions.Item>
+              <Descriptions.Item label="类型">
+                {detailData.is_gift === 1 ? (
+                  <Tag color="purple">赠送</Tag>
+                ) : (
+                  <Tag color="blue">收费</Tag>
+                )}
+              </Descriptions.Item>
+              <Descriptions.Item label="提取状态">
+                {detailData.is_extracted === 1 ? (
+                  <Tag color="green">已提取</Tag>
+                ) : (
+                  <Tag>未提取</Tag>
+                )}
+              </Descriptions.Item>
+              <Descriptions.Item label="特殊关闭">
+                {detailData.special_close === 1 ? (
+                  <Tag color="orange">打开</Tag>
+                ) : (
+                  <Tag>关闭</Tag>
+                )}
+              </Descriptions.Item>
+              <Descriptions.Item label="备注" span={2}>
+                {detailData.remark?.trim() ? detailData.remark : '-'}
+              </Descriptions.Item>
               <Descriptions.Item label="状态">
               <Descriptions.Item label="状态">
                 {detailData.status === 0 && <Tag color="default">未激活</Tag>}
                 {detailData.status === 0 && <Tag color="default">未激活</Tag>}
                 {detailData.status === 1 && <Tag color="green">已激活</Tag>}
                 {detailData.status === 1 && <Tag color="green">已激活</Tag>}
@@ -509,8 +935,18 @@ export default function Cards() {
         }}
         }}
         onOk={() => form.submit()}
         onOk={() => form.submit()}
       >
       >
-        <Form form={form} onFinish={handleGenerate} layout="vertical">
-          <Form.Item name="software_code" label="软件特征码" rules={[{ required: true }]}>
+        <Form
+          form={form}
+          onFinish={handleGenerate}
+          layout="vertical"
+          initialValues={{ is_gift: 0, discount: 1 }}
+        >
+          <Form.Item
+            name="software_code"
+            label="软件特征码"
+            rules={[{ required: true }]}
+            extra="大客户 / SteamQG 请选 buff-xp-steam-qg;普通客户端请选 buff-xp-steam-sh。两边卡密互不通用。"
+          >
             <Select>
             <Select>
               {softwareList.map((item) => (
               {softwareList.map((item) => (
                 <Select.Option key={item.code} value={item.code}>
                 <Select.Option key={item.code} value={item.code}>
@@ -522,12 +958,86 @@ export default function Cards() {
           <Form.Item name="days" label="天数" rules={[{ required: true }]}>
           <Form.Item name="days" label="天数" rules={[{ required: true }]}>
             <InputNumber min={1} style={{ width: '100%' }} />
             <InputNumber min={1} style={{ width: '100%' }} />
           </Form.Item>
           </Form.Item>
-          <Form.Item name="discount" label="折扣" rules={[{ required: true }]}>
-            <InputNumber min={0.01} max={1} step={0.01} style={{ width: '100%' }} />
+          <Form.Item name="discount" hidden>
+            <InputNumber />
           </Form.Item>
           </Form.Item>
           <Form.Item name="count" label="生成数量" rules={[{ required: true }]}>
           <Form.Item name="count" label="生成数量" rules={[{ required: true }]}>
             <InputNumber min={1} style={{ width: '100%' }} />
             <InputNumber min={1} style={{ width: '100%' }} />
           </Form.Item>
           </Form.Item>
+          <Form.Item name="is_gift" label="卡密类型" rules={[{ required: true }]}>
+            <Radio.Group>
+              <Radio value={0}>收费卡密</Radio>
+              <Radio value={1}>赠送卡密</Radio>
+            </Radio.Group>
+          </Form.Item>
+          <Form.Item
+            name="remark"
+            label="备注"
+            rules={[{ max: 500, message: '备注不超过 500 字' }]}
+          >
+            <Input.TextArea
+              rows={3}
+              placeholder="可选;本批次生成的卡密共用该备注"
+              maxLength={500}
+              showCount
+              allowClear
+            />
+          </Form.Item>
+        </Form>
+      </Modal>
+
+      {/* 发放卡密弹窗(单个/批量共用) */}
+      <Modal
+        title={issueMode === 'single' ? '发放卡密' : `批量发放卡密(${selectedRowKeys.length}张)`}
+        open={issueModalVisible}
+        confirmLoading={issueSubmitting}
+        onCancel={() => {
+          setIssueModalVisible(false)
+          issueForm.resetFields()
+        }}
+        onOk={submitIssue}
+      >
+        <Form form={issueForm} layout="vertical">
+          <Form.Item
+            name="issue_price"
+            label="发放价格"
+            rules={[{ required: true, message: '请填写发放价格' }]}
+          >
+            <InputNumber min={0} step={0.01} style={{ width: '100%' }} />
+          </Form.Item>
+          <Form.Item
+            name="issued_to"
+            label="发给谁了"
+            rules={[{ required: true, message: '请填写发放对象' }, { max: 200 }]}
+          >
+            <Input placeholder="例如:张三 / QQ123456 / 渠道A" allowClear />
+          </Form.Item>
+          <Form.Item name="issue_remark" label="发放备注" rules={[{ max: 500 }]}>
+            <Input.TextArea rows={3} maxLength={500} showCount allowClear />
+          </Form.Item>
+        </Form>
+      </Modal>
+
+      {/* 注销卡密弹窗(单个/批量共用) */}
+      <Modal
+        title={revokeMode === 'single' ? '注销卡密' : `批量注销卡密(${selectedRowKeys.length}张)`}
+        open={revokeModalVisible}
+        confirmLoading={revokeSubmitting}
+        okButtonProps={{ danger: true }}
+        okText="确认注销"
+        onCancel={() => {
+          setRevokeModalVisible(false)
+          revokeForm.resetFields()
+        }}
+        onOk={submitRevoke}
+      >
+        <div style={{ marginBottom: 12, color: '#cf1322' }}>
+          注销后该卡密将不可再使用,仅支持对“未激活”的卡密执行。
+        </div>
+        <Form form={revokeForm} layout="vertical">
+          <Form.Item name="revoked_reason" label="注销原因(可选)" rules={[{ max: 500 }]}>
+            <Input.TextArea rows={3} maxLength={500} showCount allowClear />
+          </Form.Item>
         </Form>
         </Form>
       </Modal>
       </Modal>
     </div>
     </div>

+ 18 - 0
admin/src/pages/Dashboard/index.tsx

@@ -13,6 +13,10 @@ import { dashboardApi } from '../../api/admin'
 import type { ColumnsType } from 'antd/es/table'
 import type { ColumnsType } from 'antd/es/table'
 
 
 interface StatsData {
 interface StatsData {
+  heartbeat: {
+    today_users: number
+    today_sessions: number
+  }
   users: {
   users: {
     total: number
     total: number
     today_new: number
     today_new: number
@@ -99,6 +103,20 @@ export default function Dashboard() {
       <div className="page-title">仪表盘</div>
       <div className="page-title">仪表盘</div>
       <div className="page-subtitle">系统数据概览</div>
       <div className="page-subtitle">系统数据概览</div>
       <Row gutter={16} style={{ marginTop: 24 }}>
       <Row gutter={16} style={{ marginTop: 24 }}>
+        <Col span={6}>
+          <Card>
+            <Statistic
+              title="今日心跳人数"
+              value={stats?.heartbeat.today_users || 0}
+              prefix={<UserOutlined />}
+              suffix={
+                <span style={{ fontSize: 12, color: '#999' }}>
+                  会话: {stats?.heartbeat.today_sessions || 0}
+                </span>
+              }
+            />
+          </Card>
+        </Col>
         <Col span={6}>
         <Col span={6}>
           <Card>
           <Card>
             <Statistic
             <Statistic

+ 1 - 1
admin/src/pages/Keys/index.tsx

@@ -162,7 +162,7 @@ export default function Keys() {
     }
     }
   }
   }
 
 
-  const handleViewKey = (key: Key, type: 'public' | 'private') => {
+  const handleViewKey = (key: Key, _type: 'public' | 'private') => {
     setSelectedKey(key)
     setSelectedKey(key)
     setKeyViewModalVisible(true)
     setKeyViewModalVisible(true)
   }
   }

+ 2 - 2
admin/src/pages/Login/index.css

@@ -4,7 +4,7 @@
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
-  /* use project-level image folder as background (absolute path) */
+  /* use project-level image folder '游铛铛' as background (absolute path) */
   background-image: url('/bg.jpeg');
   background-image: url('/bg.jpeg');
   background-size: cover;
   background-size: cover;
   background-position: center;
   background-position: center;
@@ -41,7 +41,7 @@
 }
 }
 
 
 .card-title-logo {
 .card-title-logo {
-  width: auto;
+  width: 60px;
   height: 60px;
   height: 60px;
   object-fit: contain;
   object-fit: contain;
   display: inline-block;
   display: inline-block;

+ 2 - 2
admin/src/pages/Login/index.tsx

@@ -47,8 +47,8 @@ export default function Login() {
         className="login-card"
         className="login-card"
         title={
         title={
           <div className="card-title">
           <div className="card-title">
-            <img className="card-title-logo" src="/logo.png" alt="Steam求购 logo" />
-            <span>Steam求购管理端登录</span>
+            <img className="card-title-logo" src="/logo.png" alt="游铛铛 logo" />
+            <span>游铛铛管理端登录</span>
           </div>
           </div>
         }
         }
       >
       >

+ 1 - 1
admin/src/pages/Users/index.tsx

@@ -11,7 +11,7 @@ import {
   Tag,
   Tag,
   Card,
   Card,
 } from 'antd'
 } from 'antd'
-import { SearchOutlined, EyeOutlined, DownloadOutlined } from '@ant-design/icons'
+import { SearchOutlined } from '@ant-design/icons'
 import { userApi, softwareApi } from '../../api/admin'
 import { userApi, softwareApi } from '../../api/admin'
 import dayjs from 'dayjs'
 import dayjs from 'dayjs'
 import { formatDate } from '../../utils/formatDate'
 import { formatDate } from '../../utils/formatDate'

+ 13 - 1
backend-admin/README.md

@@ -1,4 +1,4 @@
-# Steam求购管理端后端 API
+# 游铛铛管理端后端 API
 
 
 ## 技术栈
 ## 技术栈
 
 
@@ -32,7 +32,12 @@ backend-admin/
 
 
 ## 安装依赖
 ## 安装依赖
 
 
+建议使用项目虚拟环境,避免与 Anaconda 全局包(FastAPI/Starlette 版本不一致)冲突。需 **Python 3.10+**(代码使用了 `str | None` 等语法):
+
 ```bash
 ```bash
+# macOS 若系统 python3 为 3.9,请用 Anaconda 或 Homebrew 的 3.10+ 创建 venv,例如:
+/opt/anaconda3/bin/python -m venv venv
+source venv/bin/activate
 pip install -r requirements.txt
 pip install -r requirements.txt
 ```
 ```
 
 
@@ -56,3 +61,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 9999
 启动服务后访问:
 启动服务后访问:
 - Swagger UI: http://localhost:9999/docs
 - Swagger UI: http://localhost:9999/docs
 - ReDoc: http://localhost:9999/redoc
 - ReDoc: http://localhost:9999/redoc
+
+## 大客户卡密隔离(software_code)
+
+- 普通版客户端 / 普通卡密:`buff-xp-steam-sh`
+- 大客户 SteamQG 客户端 / 大客户卡密:`buff-xp-steam-qg`
+- 部署后请执行迁移:`migrations/20260929_add_software_buff_xp_steam_qg.sql`
+- 生成大客户卡密时必须选择「Steam求购(大客户)」;选错软件码会导致客户端提示「卡密类型不匹配」

+ 1 - 1
backend-admin/app/__init__.py

@@ -1,5 +1,5 @@
 """
 """
-Steam求购后端API
+游铛铛后端API
 """
 """
 
 
 __version__ = "1.0.0"
 __version__ = "1.0.0"

+ 546 - 11
backend-admin/app/api/admin/cards.py

@@ -1,10 +1,13 @@
 """
 """
 卡密管理接口
 卡密管理接口
 """
 """
+import logging
 from fastapi import APIRouter, Depends, Query, Request
 from fastapi import APIRouter, Depends, Query, Request
 from fastapi.responses import StreamingResponse
 from fastapi.responses import StreamingResponse
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
-from pydantic import BaseModel
+from typing import Optional
+
+from pydantic import BaseModel, Field
 import secrets
 import secrets
 import string
 import string
 import csv
 import csv
@@ -15,6 +18,8 @@ from app.models.card import Card
 from datetime import datetime
 from datetime import datetime
 from app.utils.timezone import beijing_now
 from app.utils.timezone import beijing_now
 
 
+logger = logging.getLogger(__name__)
+
 router = APIRouter()
 router = APIRouter()
 
 
 class CardGenerateRequest(BaseModel):
 class CardGenerateRequest(BaseModel):
@@ -23,6 +28,39 @@ class CardGenerateRequest(BaseModel):
     days: int
     days: int
     discount: float
     discount: float
     count: int
     count: int
+    is_gift: int = Field(0, ge=0, le=1, description="0-收费卡密,1-赠送卡密")
+    remark: Optional[str] = Field(None, max_length=500, description="备注,可选")
+
+class CardBatchExtractRequest(BaseModel):
+    """批量提取卡密请求"""
+    ids: list[int] = Field(..., min_length=1, description="卡密ID列表")
+
+class CardIssueRequest(BaseModel):
+    """发放卡密请求"""
+    issue_price: float = Field(..., ge=0, description="发放价格")
+    issued_to: str = Field(..., min_length=1, max_length=200, description="发放给谁(文本)")
+    issue_remark: Optional[str] = Field(None, max_length=500, description="发放备注,可选")
+
+class CardBatchIssueRequest(BaseModel):
+    """批量发放卡密请求"""
+    ids: list[int] = Field(..., min_length=1, description="卡密ID列表")
+    issue_price: float = Field(..., ge=0, description="发放价格")
+    issued_to: str = Field(..., min_length=1, max_length=200, description="发放给谁(文本)")
+    issue_remark: Optional[str] = Field(None, max_length=500, description="发放备注,可选")
+
+class CardRevokeRequest(BaseModel):
+    """注销卡密请求"""
+    revoked_reason: Optional[str] = Field(None, max_length=500, description="注销原因,可选")
+
+class CardBatchRevokeRequest(BaseModel):
+    """批量注销卡密请求"""
+    ids: list[int] = Field(..., min_length=1, description="卡密ID列表")
+    revoked_reason: Optional[str] = Field(None, max_length=500, description="注销原因,可选")
+
+class CardBatchSpecialCloseRequest(BaseModel):
+    """批量设置特殊关闭请求"""
+    ids: list[int] = Field(..., min_length=1, description="卡密ID列表")
+    special_close: int = Field(..., ge=0, le=1, description="特殊关闭:0-关闭,1-打开")
 
 
 def generate_card_code() -> str:
 def generate_card_code() -> str:
     """生成卡密代码"""
     """生成卡密代码"""
@@ -38,6 +76,8 @@ async def generate_cards(
 ):
 ):
     """生成卡密"""
     """生成卡密"""
     try:
     try:
+        remark_clean = (req.remark or "").strip() or None
+
         cards = []
         cards = []
         for _ in range(req.count):
         for _ in range(req.count):
             card_code = generate_card_code()
             card_code = generate_card_code()
@@ -51,6 +91,8 @@ async def generate_cards(
                 days=req.days,
                 days=req.days,
                 discount=req.discount,
                 discount=req.discount,
                 status=0,
                 status=0,
+                is_gift=req.is_gift,
+                remark=remark_clean,
                 created_at=beijing_now(),
                 created_at=beijing_now(),
                 updated_at=beijing_now()
                 updated_at=beijing_now()
             )
             )
@@ -67,17 +109,20 @@ async def generate_cards(
 
 
         # 记录操作日志
         # 记录操作日志
         client_ip = request.client.host if request.client else None
         client_ip = request.client.host if request.client else None
+        gift_label = "赠送" if req.is_gift == 1 else "收费"
         create_operation_log(
         create_operation_log(
             db=db,
             db=db,
             admin_id=current_admin.id,
             admin_id=current_admin.id,
             module="卡密管理",
             module="卡密管理",
             action="生成卡密",
             action="生成卡密",
-            description=f"批量生成卡密:{req.count}张,软件:{req.software_code},天数:{req.days}天",
+            description=f"批量生成卡密:{req.count}张({gift_label}),软件:{req.software_code},天数:{req.days}天",
             new_data={
             new_data={
                 "software_code": req.software_code,
                 "software_code": req.software_code,
                 "days": req.days,
                 "days": req.days,
                 "discount": req.discount,
                 "discount": req.discount,
                 "count": req.count,
                 "count": req.count,
+                "is_gift": req.is_gift,
+                "remark": remark_clean,
                 "card_codes": [c.card_code for c in cards]
                 "card_codes": [c.card_code for c in cards]
             },
             },
             ip_address=client_ip
             ip_address=client_ip
@@ -108,14 +153,19 @@ async def generate_cards(
 async def get_cards(
 async def get_cards(
     page: int = Query(1, ge=1),
     page: int = Query(1, ge=1),
     page_size: int = Query(10, ge=1, le=100),
     page_size: int = Query(10, ge=1, le=100),
-    keyword: str = Query(None, description="搜索关键词(卡密代码)"),
+    keyword: str = Query(None, description="搜索关键词(卡密代码或备注)"),
     software_code: str = Query(None, description="软件特征码筛选"),
     software_code: str = Query(None, description="软件特征码筛选"),
     status: int = Query(None, description="状态筛选:0-未激活,1-已激活,2-已过期"),
     status: int = Query(None, description="状态筛选:0-未激活,1-已激活,2-已过期"),
+    is_gift: int = Query(None, ge=0, le=1, description="类型筛选:0-收费,1-赠送"),
+    is_extracted: int = Query(None, ge=0, le=1, description="提取状态筛选:0-未提取,1-已提取"),
+    is_revoked: int = Query(None, ge=0, le=1, description="注销筛选:0-未注销,1-已注销"),
+    special_close: int = Query(None, ge=0, le=1, description="特殊关闭筛选:0-关闭,1-打开"),
     db: Session = Depends(get_db),
     db: Session = Depends(get_db),
     current_admin = Depends(get_current_admin)
     current_admin = Depends(get_current_admin)
 ):
 ):
     """获取卡密列表"""
     """获取卡密列表"""
     from sqlalchemy import or_
     from sqlalchemy import or_
+
     from app.models.software import Software
     from app.models.software import Software
     from app.models.user import User
     from app.models.user import User
 
 
@@ -129,7 +179,10 @@ async def get_cards(
 
 
     # 搜索
     # 搜索
     if keyword:
     if keyword:
-        query = query.filter(Card.card_code.like(f"%{keyword}%"))
+        like = f"%{keyword}%"
+        query = query.filter(
+            or_(Card.card_code.like(like), Card.remark.like(like))
+        )
 
 
     # 筛选
     # 筛选
     if software_code:
     if software_code:
@@ -138,8 +191,20 @@ async def get_cards(
     if status is not None:
     if status is not None:
         query = query.filter(Card.status == status)
         query = query.filter(Card.status == status)
 
 
-    # 排序
-    query = query.order_by(Card.created_at.desc())
+    if is_gift is not None:
+        query = query.filter(Card.is_gift == is_gift)
+
+    if is_extracted is not None:
+        query = query.filter(Card.is_extracted == is_extracted)
+
+    if is_revoked is not None:
+        query = query.filter(Card.is_revoked == is_revoked)
+
+    if special_close is not None:
+        query = query.filter(Card.special_close == special_close)
+
+    # 排序(同创建时间时按 id 降序,保证更新后顺序稳定)
+    query = query.order_by(Card.created_at.desc(), Card.id.desc())
 
 
     cards_with_names = query.offset(offset).limit(page_size).all()
     cards_with_names = query.offset(offset).limit(page_size).all()
     total = query.count()
     total = query.count()
@@ -156,6 +221,19 @@ async def get_cards(
                 "days": c.days,
                 "days": c.days,
                 "discount": float(c.discount),
                 "discount": float(c.discount),
                 "status": c.status,
                 "status": c.status,
+                "is_gift": c.is_gift,
+                "is_extracted": c.is_extracted,
+                "special_close": c.special_close,
+                "issue_price": float(c.issue_price) if c.issue_price is not None else None,
+                "issued_to": c.issued_to,
+                "issue_remark": c.issue_remark,
+                "issued_at": c.issued_at.isoformat() if c.issued_at else None,
+                "issued_admin_id": c.issued_admin_id,
+                "remark": c.remark,
+                "is_revoked": c.is_revoked,
+                "revoked_at": c.revoked_at.isoformat() if c.revoked_at else None,
+                "revoked_reason": c.revoked_reason,
+                "revoked_admin_id": c.revoked_admin_id,
                 "user_id": c.user_id,
                 "user_id": c.user_id,
                 "username": username,
                 "username": username,
                 "activated_at": c.activated_at.isoformat() if c.activated_at else None,
                 "activated_at": c.activated_at.isoformat() if c.activated_at else None,
@@ -170,6 +248,270 @@ async def get_cards(
         "page_size": page_size
         "page_size": page_size
     }
     }
 
 
+@router.put("/{card_id}/extract")
+async def extract_card(
+    card_id: int,
+    request: Request,
+    db: Session = Depends(get_db),
+    current_admin = Depends(get_current_admin)
+):
+    """提取单个卡密(标记为已提取)"""
+    from app.utils.history import create_card_history, create_operation_log
+
+    try:
+        card = db.query(Card).filter(Card.id == card_id, Card.is_deleted == 0).first()
+        if not card:
+            return {"code": 404, "msg": "卡密不存在"}
+
+        card.is_extracted = 1
+        card.updated_at = beijing_now()
+
+        create_card_history(db, card, "UPDATE")
+        client_ip = request.client.host if request.client else None
+        create_operation_log(
+            db=db,
+            admin_id=current_admin.id,
+            module="卡密管理",
+            action="提取卡密",
+            description=f"提取卡密:{card.card_code}",
+            new_data={"card_id": card.id, "card_code": card.card_code, "is_extracted": 1},
+            ip_address=client_ip
+        )
+        db.commit()
+
+        return {
+            "code": 200,
+            "msg": "提取成功",
+            "data": {"id": card.id, "card_code": card.card_code, "is_extracted": card.is_extracted}
+        }
+    except Exception:
+        db.rollback()
+        logger.exception("提取卡密失败")
+        return {"code": 500, "msg": "系统异常,操作未完成,请稍后重试;若持续发生请联系客服"}
+
+@router.put("/{card_id}/issue")
+async def issue_card(
+    card_id: int,
+    req: CardIssueRequest,
+    request: Request,
+    db: Session = Depends(get_db),
+    current_admin = Depends(get_current_admin)
+):
+    """发放单个卡密(记录发放信息)"""
+    from app.utils.history import create_card_history, create_operation_log
+
+    try:
+        card = db.query(Card).filter(Card.id == card_id, Card.is_deleted == 0).first()
+        if not card:
+            return {"code": 404, "msg": "卡密不存在"}
+
+        old_data = {
+            "issue_price": float(card.issue_price) if card.issue_price is not None else None,
+            "issued_to": card.issued_to,
+            "issue_remark": card.issue_remark,
+            "issued_at": card.issued_at.isoformat() if card.issued_at else None,
+            "issued_admin_id": card.issued_admin_id,
+        }
+
+        now = beijing_now()
+        card.issue_price = req.issue_price
+        card.issued_to = req.issued_to.strip()
+        card.issue_remark = (req.issue_remark or "").strip() or None
+        card.issued_at = now
+        card.issued_admin_id = current_admin.id
+        card.updated_at = now
+
+        create_card_history(db, card, "UPDATE")
+        client_ip = request.client.host if request.client else None
+        create_operation_log(
+            db=db,
+            admin_id=current_admin.id,
+            module="卡密管理",
+            action="发放卡密",
+            description=f"发放卡密:{card.card_code}",
+            old_data=old_data,
+            new_data={
+                "card_id": card.id,
+                "card_code": card.card_code,
+                "issue_price": req.issue_price,
+                "issued_to": card.issued_to,
+                "issue_remark": card.issue_remark,
+                "issued_at": now.isoformat(),
+                "issued_admin_id": current_admin.id,
+            },
+            ip_address=client_ip
+        )
+        db.commit()
+
+        return {"code": 200, "msg": "发放成功"}
+    except Exception:
+        db.rollback()
+        logger.exception("发放卡密失败")
+        return {"code": 500, "msg": "系统异常,操作未完成,请稍后重试;若持续发生请联系客服"}
+
+@router.post("/issue")
+async def issue_cards_batch(
+    req: CardBatchIssueRequest,
+    request: Request,
+    db: Session = Depends(get_db),
+    current_admin = Depends(get_current_admin)
+):
+    """批量发放卡密(记录发放信息)"""
+    from app.utils.history import create_card_history, create_operation_log
+
+    try:
+        cards = db.query(Card).filter(
+            Card.id.in_(req.ids),
+            Card.is_deleted == 0
+        ).all()
+
+        if not cards:
+            return {"code": 404, "msg": "未找到可发放的卡密"}
+
+        now = beijing_now()
+        issued_to_clean = req.issued_to.strip()
+        issue_remark_clean = (req.issue_remark or "").strip() or None
+
+        card_codes = []
+        for card in cards:
+            card.issue_price = req.issue_price
+            card.issued_to = issued_to_clean
+            card.issue_remark = issue_remark_clean
+            card.issued_at = now
+            card.issued_admin_id = current_admin.id
+            card.updated_at = now
+            card_codes.append(card.card_code)
+            create_card_history(db, card, "UPDATE")
+
+        client_ip = request.client.host if request.client else None
+        create_operation_log(
+            db=db,
+            admin_id=current_admin.id,
+            module="卡密管理",
+            action="批量发放卡密",
+            description=f"批量发放卡密:{len(card_codes)}张",
+            new_data={
+                "card_ids": req.ids,
+                "card_codes": card_codes,
+                "issue_price": req.issue_price,
+                "issued_to": issued_to_clean,
+                "issue_remark": issue_remark_clean,
+                "issued_at": now.isoformat(),
+                "issued_admin_id": current_admin.id,
+            },
+            ip_address=client_ip
+        )
+        db.commit()
+
+        return {"code": 200, "msg": "批量发放成功", "data": {"count": len(card_codes)}}
+    except Exception:
+        db.rollback()
+        logger.exception("批量发放卡密失败")
+        return {"code": 500, "msg": "系统异常,操作未完成,请稍后重试;若持续发生请联系客服"}
+
+@router.post("/extract")
+async def extract_cards_batch(
+    req: CardBatchExtractRequest,
+    request: Request,
+    db: Session = Depends(get_db),
+    current_admin = Depends(get_current_admin)
+):
+    """批量提取卡密(标记为已提取)"""
+    from app.utils.history import create_card_history, create_operation_log
+
+    try:
+        cards = db.query(Card).filter(
+            Card.id.in_(req.ids),
+            Card.is_deleted == 0
+        ).all()
+
+        if not cards:
+            return {"code": 404, "msg": "未找到可提取的卡密"}
+
+        now = beijing_now()
+        card_codes = []
+        for card in cards:
+            card.is_extracted = 1
+            card.updated_at = now
+            card_codes.append(card.card_code)
+            create_card_history(db, card, "UPDATE")
+
+        client_ip = request.client.host if request.client else None
+        create_operation_log(
+            db=db,
+            admin_id=current_admin.id,
+            module="卡密管理",
+            action="批量提取卡密",
+            description=f"批量提取卡密:{len(card_codes)}张",
+            new_data={"card_ids": req.ids, "card_codes": card_codes, "is_extracted": 1},
+            ip_address=client_ip
+        )
+        db.commit()
+
+        return {
+            "code": 200,
+            "msg": "批量提取成功",
+            "data": {"count": len(card_codes), "cards": card_codes}
+        }
+    except Exception:
+        db.rollback()
+        logger.exception("批量提取卡密失败")
+        return {"code": 500, "msg": "系统异常,操作未完成,请稍后重试;若持续发生请联系客服"}
+
+@router.post("/special-close")
+async def batch_set_special_close(
+    req: CardBatchSpecialCloseRequest,
+    request: Request,
+    db: Session = Depends(get_db),
+    current_admin = Depends(get_current_admin)
+):
+    """批量设置卡密特殊关闭标识(0-关闭,1-打开)"""
+    from app.utils.history import create_card_history, create_operation_log
+
+    try:
+        cards = db.query(Card).filter(
+            Card.id.in_(req.ids),
+            Card.is_deleted == 0
+        ).all()
+
+        if not cards:
+            return {"code": 404, "msg": "未找到可操作的卡密"}
+
+        now = beijing_now()
+        card_codes = []
+        for card in cards:
+            card.special_close = req.special_close
+            card.updated_at = now
+            card_codes.append(card.card_code)
+            create_card_history(db, card, "UPDATE")
+
+        action_label = "打开" if req.special_close == 1 else "关闭"
+        client_ip = request.client.host if request.client else None
+        create_operation_log(
+            db=db,
+            admin_id=current_admin.id,
+            module="卡密管理",
+            action=f"批量特殊关闭{action_label}",
+            description=f"批量特殊关闭{action_label}:{len(card_codes)}张",
+            new_data={
+                "card_ids": req.ids,
+                "card_codes": card_codes,
+                "special_close": req.special_close,
+            },
+            ip_address=client_ip
+        )
+        db.commit()
+
+        return {
+            "code": 200,
+            "msg": f"批量特殊关闭{action_label}成功",
+            "data": {"count": len(card_codes), "special_close": req.special_close}
+        }
+    except Exception:
+        db.rollback()
+        logger.exception("批量设置特殊关闭失败")
+        return {"code": 500, "msg": "系统异常,操作未完成,请稍后重试;若持续发生请联系客服"}
+
 @router.get("/{card_id}")
 @router.get("/{card_id}")
 async def get_card_detail(
 async def get_card_detail(
     card_id: int,
     card_id: int,
@@ -204,6 +546,19 @@ async def get_card_detail(
             "days": card.days,
             "days": card.days,
             "discount": float(card.discount),
             "discount": float(card.discount),
             "status": card.status,
             "status": card.status,
+            "is_gift": card.is_gift,
+            "is_extracted": card.is_extracted,
+            "special_close": card.special_close,
+            "issue_price": float(card.issue_price) if card.issue_price is not None else None,
+            "issued_to": card.issued_to,
+            "issue_remark": card.issue_remark,
+            "issued_at": card.issued_at.isoformat() if card.issued_at else None,
+            "issued_admin_id": card.issued_admin_id,
+            "remark": card.remark,
+            "is_revoked": card.is_revoked,
+            "revoked_at": card.revoked_at.isoformat() if card.revoked_at else None,
+            "revoked_reason": card.revoked_reason,
+            "revoked_admin_id": card.revoked_admin_id,
             "user_id": card.user_id,
             "user_id": card.user_id,
             "user_info": user_info,
             "user_info": user_info,
             "activated_at": card.activated_at.isoformat() if card.activated_at else None,
             "activated_at": card.activated_at.isoformat() if card.activated_at else None,
@@ -213,6 +568,136 @@ async def get_card_detail(
         }
         }
     }
     }
 
 
+@router.put("/{card_id}/revoke")
+async def revoke_card(
+    card_id: int,
+    req: CardRevokeRequest,
+    request: Request,
+    db: Session = Depends(get_db),
+    current_admin = Depends(get_current_admin),
+):
+    """注销单个卡密(标记为不可再使用)"""
+    from app.utils.history import create_card_history, create_operation_log
+
+    try:
+        card = db.query(Card).filter(Card.id == card_id, Card.is_deleted == 0).first()
+        if not card:
+            return {"code": 404, "msg": "卡密不存在"}
+        if card.is_revoked == 1:
+            return {"code": 400, "msg": "该卡密已注销"}
+        if card.status != 0:
+            return {"code": 400, "msg": "仅支持注销未激活的卡密"}
+
+        old_data = {
+            "is_revoked": card.is_revoked,
+            "revoked_at": card.revoked_at.isoformat() if card.revoked_at else None,
+            "revoked_reason": card.revoked_reason,
+            "revoked_admin_id": card.revoked_admin_id,
+        }
+
+        now = beijing_now()
+        card.is_revoked = 1
+        card.is_deleted = 1
+        card.revoked_at = now
+        card.revoked_reason = (req.revoked_reason or "").strip() or None
+        card.revoked_admin_id = current_admin.id
+        card.updated_at = now
+
+        create_card_history(db, card, "UPDATE")
+        client_ip = request.client.host if request.client else None
+        create_operation_log(
+            db=db,
+            admin_id=current_admin.id,
+            module="卡密管理",
+            action="注销卡密",
+            description=f"注销卡密:{card.card_code}",
+            old_data=old_data,
+            new_data={
+                "card_id": card.id,
+                "card_code": card.card_code,
+                "is_revoked": 1,
+                "is_deleted": 1,
+                "revoked_at": now.isoformat(),
+                "revoked_reason": card.revoked_reason,
+                "revoked_admin_id": current_admin.id,
+            },
+            ip_address=client_ip,
+        )
+        db.commit()
+        return {"code": 200, "msg": "注销成功"}
+    except Exception:
+        db.rollback()
+        logger.exception("注销卡密失败")
+        return {"code": 500, "msg": "系统异常,操作未完成,请稍后重试;若持续发生请联系客服"}
+
+
+@router.post("/revoke")
+async def revoke_cards_batch(
+    req: CardBatchRevokeRequest,
+    request: Request,
+    db: Session = Depends(get_db),
+    current_admin = Depends(get_current_admin),
+):
+    """批量注销卡密(标记为不可再使用)"""
+    from app.utils.history import create_card_history, create_operation_log
+
+    try:
+        cards = db.query(Card).filter(
+            Card.id.in_(req.ids),
+            Card.is_deleted == 0,
+        ).all()
+        if not cards:
+            return {"code": 404, "msg": "未找到可注销的卡密"}
+
+        now = beijing_now()
+        reason_clean = (req.revoked_reason or "").strip() or None
+
+        revoked_codes: list[str] = []
+        skipped: list[dict] = []
+        for card in cards:
+            if card.is_revoked == 1:
+                skipped.append({"id": card.id, "card_code": card.card_code, "reason": "已注销"})
+                continue
+            if card.status != 0:
+                skipped.append({"id": card.id, "card_code": card.card_code, "reason": "非未激活"})
+                continue
+            card.is_revoked = 1
+            card.is_deleted = 1
+            card.revoked_at = now
+            card.revoked_reason = reason_clean
+            card.revoked_admin_id = current_admin.id
+            card.updated_at = now
+            revoked_codes.append(card.card_code)
+            create_card_history(db, card, "UPDATE")
+
+        if not revoked_codes:
+            return {"code": 400, "msg": "没有可注销的卡密", "data": {"skipped": skipped}}
+
+        client_ip = request.client.host if request.client else None
+        create_operation_log(
+            db=db,
+            admin_id=current_admin.id,
+            module="卡密管理",
+            action="批量注销卡密",
+            description=f"批量注销卡密:{len(revoked_codes)}张",
+            new_data={
+                "card_ids": req.ids,
+                "revoked_card_codes": revoked_codes,
+                "revoked_at": now.isoformat(),
+                "revoked_reason": reason_clean,
+                "revoked_admin_id": current_admin.id,
+                "is_deleted": 1,
+                "skipped": skipped,
+            },
+            ip_address=client_ip,
+        )
+        db.commit()
+        return {"code": 200, "msg": "批量注销成功", "data": {"count": len(revoked_codes), "skipped": skipped}}
+    except Exception:
+        db.rollback()
+        logger.exception("批量注销卡密失败")
+        return {"code": 500, "msg": "系统异常,操作未完成,请稍后重试;若持续发生请联系客服"}
+
 @router.get("/{card_id}/activate")
 @router.get("/{card_id}/activate")
 async def get_card_activate_info(
 async def get_card_activate_info(
     card_id: int,
     card_id: int,
@@ -231,7 +716,12 @@ async def get_card_activate_info(
             "card_code": card.card_code,
             "card_code": card.card_code,
             "user_id": card.user_id,
             "user_id": card.user_id,
             "activated_at": card.activated_at.isoformat() if card.activated_at else None,
             "activated_at": card.activated_at.isoformat() if card.activated_at else None,
-            "expire_time": card.expire_time.isoformat() if card.expire_time else None
+                "expire_time": card.expire_time.isoformat() if card.expire_time else None,
+                "issue_price": float(card.issue_price) if card.issue_price is not None else None,
+                "issued_to": card.issued_to,
+                "issue_remark": card.issue_remark,
+                "issued_at": card.issued_at.isoformat() if card.issued_at else None,
+                "issued_admin_id": card.issued_admin_id,
         }
         }
     }
     }
 
 
@@ -241,10 +731,16 @@ async def export_cards_excel(
     keyword: str = Query(None, description="搜索关键词"),
     keyword: str = Query(None, description="搜索关键词"),
     software_code: str = Query(None, description="软件特征码筛选"),
     software_code: str = Query(None, description="软件特征码筛选"),
     status: int = Query(None, description="状态筛选"),
     status: int = Query(None, description="状态筛选"),
+    is_gift: int = Query(None, ge=0, le=1, description="类型筛选:0-收费,1-赠送"),
+    is_extracted: int = Query(None, ge=0, le=1, description="提取状态筛选:0-未提取,1-已提取"),
+    is_revoked: int = Query(None, ge=0, le=1, description="注销筛选:0-未注销,1-已注销"),
+    special_close: int = Query(None, ge=0, le=1, description="特殊关闭筛选:0-关闭,1-打开"),
     db: Session = Depends(get_db),
     db: Session = Depends(get_db),
     current_admin = Depends(get_current_admin)
     current_admin = Depends(get_current_admin)
 ):
 ):
     """导出卡密为Excel"""
     """导出卡密为Excel"""
+    from sqlalchemy import or_
+
     from app.models.software import Software
     from app.models.software import Software
     from app.models.user import User
     from app.models.user import User
 
 
@@ -256,7 +752,10 @@ async def export_cards_excel(
 
 
     # 搜索
     # 搜索
     if keyword:
     if keyword:
-        query = query.filter(Card.card_code.like(f"%{keyword}%"))
+        like = f"%{keyword}%"
+        query = query.filter(
+            or_(Card.card_code.like(like), Card.remark.like(like))
+        )
 
 
     # 筛选
     # 筛选
     if software_code:
     if software_code:
@@ -265,7 +764,19 @@ async def export_cards_excel(
     if status is not None:
     if status is not None:
         query = query.filter(Card.status == status)
         query = query.filter(Card.status == status)
 
 
-    cards_with_names = query.order_by(Card.created_at.desc()).all()
+    if is_gift is not None:
+        query = query.filter(Card.is_gift == is_gift)
+
+    if is_extracted is not None:
+        query = query.filter(Card.is_extracted == is_extracted)
+
+    if is_revoked is not None:
+        query = query.filter(Card.is_revoked == is_revoked)
+
+    if special_close is not None:
+        query = query.filter(Card.special_close == special_close)
+
+    cards_with_names = query.order_by(Card.created_at.desc(), Card.id.desc()).all()
 
 
     # 记录操作日志
     # 记录操作日志
     from app.utils.history import create_operation_log
     from app.utils.history import create_operation_log
@@ -282,7 +793,11 @@ async def export_cards_excel(
             "filters": {
             "filters": {
                 "keyword": keyword,
                 "keyword": keyword,
                 "software_code": software_code,
                 "software_code": software_code,
-                "status": status
+                "status": status,
+                "is_gift": is_gift,
+                "is_extracted": is_extracted,
+                "is_revoked": is_revoked,
+                "special_close": special_close,
             }
             }
         },
         },
         ip_address=client_ip
         ip_address=client_ip
@@ -293,11 +808,18 @@ async def export_cards_excel(
     output = io.StringIO()
     output = io.StringIO()
     writer = csv.writer(output)
     writer = csv.writer(output)
     writer.writerow([
     writer.writerow([
-        'ID', '卡密代码', '软件特征码', '软件名称', '天数', '折扣', '状态',
+        'ID', '卡密代码', '软件特征码', '软件名称', '天数', '折扣', '状态', '卡密类型', '提取状态', '特殊关闭',
+        '注销状态', '注销时间', '注销原因', '注销管理员ID',
+        '发放价格', '发放对象', '发放备注', '发放时间', '发放管理员ID',
+        '备注',
         '激活用户ID', '用户名称', '激活时间', '到期时间', '创建时间', '更新时间'
         '激活用户ID', '用户名称', '激活时间', '到期时间', '创建时间', '更新时间'
     ])
     ])
 
 
     status_map = {0: '未激活', 1: '已激活', 2: '已过期'}
     status_map = {0: '未激活', 1: '已激活', 2: '已过期'}
+    gift_map = {0: '收费', 1: '赠送'}
+    extracted_map = {0: '未提取', 1: '已提取'}
+    special_close_map = {0: '关闭', 1: '打开'}
+    revoked_map = {0: '未注销', 1: '已注销'}
     for card, software_name, username in cards_with_names:
     for card, software_name, username in cards_with_names:
         writer.writerow([
         writer.writerow([
             card.id,
             card.id,
@@ -307,6 +829,19 @@ async def export_cards_excel(
             card.days,
             card.days,
             float(card.discount),
             float(card.discount),
             status_map.get(card.status, '未知'),
             status_map.get(card.status, '未知'),
+            gift_map.get(card.is_gift, '未知'),
+            extracted_map.get(card.is_extracted, '未知'),
+            special_close_map.get(getattr(card, "special_close", 0) or 0, '未知'),
+            revoked_map.get(getattr(card, "is_revoked", 0), '未知'),
+            card.revoked_at.isoformat() if getattr(card, "revoked_at", None) else '',
+            (card.revoked_reason or '') if getattr(card, "revoked_reason", None) is not None else '',
+            card.revoked_admin_id or '',
+            float(card.issue_price) if card.issue_price is not None else '',
+            card.issued_to or '',
+            card.issue_remark or '',
+            card.issued_at.isoformat() if card.issued_at else '',
+            card.issued_admin_id or '',
+            card.remark or '',
             card.user_id or '',
             card.user_id or '',
             username or '',
             username or '',
             card.activated_at.isoformat() if card.activated_at else '',
             card.activated_at.isoformat() if card.activated_at else '',

+ 23 - 0
backend-admin/app/api/admin/dashboard.py

@@ -10,6 +10,7 @@ from app.models.user import User
 from app.models.software import Software
 from app.models.software import Software
 from app.models.card import Card
 from app.models.card import Card
 from app.models.api_log import APILog
 from app.models.api_log import APILog
+from app.models.session import Session as UserSession
 from app.background_tasks import get_task_manager
 from app.background_tasks import get_task_manager
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 from app.utils.timezone import beijing_now
 from app.utils.timezone import beijing_now
@@ -104,6 +105,24 @@ async def get_dashboard_stats(
         }
         }
         for row in recent_logs
         for row in recent_logs
     ]
     ]
+
+    # 今日心跳/活跃人数(按会话 last_activity 去重 user_id)
+    today_start_naive = beijing_now().replace(tzinfo=None, hour=0, minute=0, second=0, microsecond=0)
+    heartbeat_today_users = db.query(
+        func.count(func.distinct(UserSession.user_id))
+    ).filter(
+        and_(
+            UserSession.is_active == 1,
+            UserSession.last_activity >= today_start_naive
+        )
+    ).scalar() or 0
+
+    heartbeat_today_sessions = db.query(func.count(UserSession.id)).filter(
+        and_(
+            UserSession.is_active == 1,
+            UserSession.last_activity >= today_start_naive
+        )
+    ).scalar() or 0
     
     
     # 获取后台任务状态
     # 获取后台任务状态
     task_manager = get_task_manager()
     task_manager = get_task_manager()
@@ -118,6 +137,10 @@ async def get_dashboard_stats(
         "code": 200,
         "code": 200,
         "msg": "成功",
         "msg": "成功",
         "data": {
         "data": {
+            "heartbeat": {
+                "today_users": heartbeat_today_users,
+                "today_sessions": heartbeat_today_sessions
+            },
             "users": {
             "users": {
                 "total": total_users,
                 "total": total_users,
                 "today_new": today_new_users,
                 "today_new": today_new_users,

+ 2 - 2
backend-admin/app/config.py

@@ -13,10 +13,10 @@ class Settings:
     """应用配置"""
     """应用配置"""
     
     
     # 数据库配置
     # 数据库配置
-    DB_HOST: str = os.getenv("DB_HOST", "localhost")
+    DB_HOST: str = os.getenv("DB_HOST", "125.122.32.114")
     DB_PORT: int = int(os.getenv("DB_PORT", "3306"))
     DB_PORT: int = int(os.getenv("DB_PORT", "3306"))
     DB_USER: str = os.getenv("DB_USER", "root")
     DB_USER: str = os.getenv("DB_USER", "root")
-    DB_PASSWORD: str = os.getenv("DB_PASSWORD", "")
+    DB_PASSWORD: str = os.getenv("DB_PASSWORD", "Buff123456")
     DB_NAME: str = os.getenv("DB_NAME", "buff")
     DB_NAME: str = os.getenv("DB_NAME", "buff")
 
 
     # 数据库URL
     # 数据库URL

+ 3 - 3
backend-admin/app/main.py

@@ -20,8 +20,8 @@ logger.info(f"系统配置加载完成,共 {len(config_cache.get_all())} 项
 
 
 # 创建FastAPI应用
 # 创建FastAPI应用
 app = FastAPI(
 app = FastAPI(
-    title="Steam求购管理端API",
-    description="Steam求购产品管理端接口服务",
+    title="游铛铛管理端API",
+    description="游铛铛产品管理端接口服务",
     version="1.0.0",
     version="1.0.0",
     docs_url="/docs",
     docs_url="/docs",
     redoc_url="/redoc",
     redoc_url="/redoc",
@@ -81,7 +81,7 @@ app.include_router(updater_router, prefix="/admin", tags=["更新统计"])
 @app.get("/")
 @app.get("/")
 async def root():
 async def root():
     """根路径"""
     """根路径"""
-    return {"message": "Steam求购管理端API", "version": "1.0.0"}
+    return {"message": "游铛铛管理端API", "version": "1.0.0"}
 
 
 @app.get("/health")
 @app.get("/health")
 async def health():
 async def health():

+ 13 - 1
backend-admin/app/models/card.py

@@ -18,8 +18,20 @@ class Card(Base):
     activated_at = Column(DateTime, comment="激活时间")
     activated_at = Column(DateTime, comment="激活时间")
     expire_time = Column(DateTime, index=True, comment="到期时间")
     expire_time = Column(DateTime, index=True, comment="到期时间")
     status = Column(SmallInteger, default=0, index=True, comment="状态:0-未激活,1-已激活,2-已过期")
     status = Column(SmallInteger, default=0, index=True, comment="状态:0-未激活,1-已激活,2-已过期")
+    is_gift = Column(SmallInteger, default=0, index=True, comment="是否赠送卡密:0-收费,1-赠送")
+    is_extracted = Column(SmallInteger, default=0, index=True, comment="是否已提取:0-未提取,1-已提取")
+    special_close = Column(SmallInteger, default=0, index=True, comment="特殊关闭:0-关闭,1-打开")
+    issue_price = Column(Numeric(10, 2), nullable=True, comment="发放价格")
+    issued_to = Column(String(200), nullable=True, comment="发放给谁(文本)")
+    issue_remark = Column(String(500), nullable=True, comment="发放备注")
+    issued_at = Column(DateTime, index=True, nullable=True, comment="发放时间")
+    issued_admin_id = Column(Integer, index=True, nullable=True, comment="发放管理员ID")
+    remark = Column(String(500), nullable=True, comment="备注")
     is_deleted = Column(SmallInteger, default=0, index=True, comment="是否删除:0-否,1-是")
     is_deleted = Column(SmallInteger, default=0, index=True, comment="是否删除:0-否,1-是")
-    special_close = Column(SmallInteger, default=0, nullable=False, comment="特殊关闭:0-关闭,1-打开")
+    is_revoked = Column(SmallInteger, default=0, index=True, comment="是否已注销:0-否,1-是")
+    revoked_at = Column(DateTime, index=True, nullable=True, comment="注销时间")
+    revoked_reason = Column(String(500), nullable=True, comment="注销原因")
+    revoked_admin_id = Column(Integer, index=True, nullable=True, comment="注销管理员ID")
     created_at = Column(DateTime, comment="创建时间")
     created_at = Column(DateTime, comment="创建时间")
     updated_at = Column(DateTime, comment="更新时间")
     updated_at = Column(DateTime, comment="更新时间")
     
     

+ 13 - 0
backend-admin/app/models/card_his.py

@@ -18,7 +18,20 @@ class CardHis(Base):
     activated_at = Column(DateTime, comment="激活时间")
     activated_at = Column(DateTime, comment="激活时间")
     expire_time = Column(DateTime, comment="到期时间")
     expire_time = Column(DateTime, comment="到期时间")
     status = Column(SmallInteger, default=0, comment="状态:0-未激活,1-已激活,2-已过期")
     status = Column(SmallInteger, default=0, comment="状态:0-未激活,1-已激活,2-已过期")
+    is_gift = Column(SmallInteger, default=0, comment="是否赠送卡密:0-收费,1-赠送")
+    is_extracted = Column(SmallInteger, default=0, comment="是否已提取:0-未提取,1-已提取")
+    special_close = Column(SmallInteger, default=0, comment="特殊关闭:0-关闭,1-打开")
+    issue_price = Column(Numeric(10, 2), nullable=True, comment="发放价格")
+    issued_to = Column(String(200), nullable=True, comment="发放给谁(文本)")
+    issue_remark = Column(String(500), nullable=True, comment="发放备注")
+    issued_at = Column(DateTime, comment="发放时间")
+    issued_admin_id = Column(Integer, comment="发放管理员ID")
+    remark = Column(String(500), nullable=True, comment="备注")
     is_deleted = Column(SmallInteger, default=0, comment="是否删除:0-否,1-是")
     is_deleted = Column(SmallInteger, default=0, comment="是否删除:0-否,1-是")
+    is_revoked = Column(SmallInteger, default=0, comment="是否已注销:0-否,1-是")
+    revoked_at = Column(DateTime, nullable=True, comment="注销时间")
+    revoked_reason = Column(String(500), nullable=True, comment="注销原因")
+    revoked_admin_id = Column(Integer, nullable=True, comment="注销管理员ID")
     created_at = Column(DateTime, comment="创建时间")
     created_at = Column(DateTime, comment="创建时间")
     updated_at = Column(DateTime, comment="更新时间")
     updated_at = Column(DateTime, comment="更新时间")
     his_created_at = Column(DateTime, comment="历史记录创建时间")
     his_created_at = Column(DateTime, comment="历史记录创建时间")

+ 13 - 0
backend-admin/app/utils/history.py

@@ -110,7 +110,20 @@ def create_card_history(db: Session, card: Card, operation: str):
         activated_at=card.activated_at,
         activated_at=card.activated_at,
         expire_time=card.expire_time,
         expire_time=card.expire_time,
         status=card.status,
         status=card.status,
+        is_gift=card.is_gift,
+        is_extracted=card.is_extracted,
+        special_close=getattr(card, "special_close", 0) or 0,
+        issue_price=card.issue_price,
+        issued_to=card.issued_to,
+        issue_remark=card.issue_remark,
+        issued_at=card.issued_at,
+        issued_admin_id=card.issued_admin_id,
+        remark=card.remark,
         is_deleted=card.is_deleted,
         is_deleted=card.is_deleted,
+        is_revoked=getattr(card, "is_revoked", 0) or 0,
+        revoked_at=getattr(card, "revoked_at", None),
+        revoked_reason=getattr(card, "revoked_reason", None),
+        revoked_admin_id=getattr(card, "revoked_admin_id", None),
         created_at=card.created_at,
         created_at=card.created_at,
         updated_at=card.updated_at,
         updated_at=card.updated_at,
         his_created_at=beijing_now(),
         his_created_at=beijing_now(),

+ 6 - 0
backend-admin/migrations/20260411_add_cards_is_gift.sql

@@ -0,0 +1,6 @@
+-- 卡密:区分收费 / 赠送(0 收费,1 赠送)
+ALTER TABLE `cards`
+  ADD COLUMN `is_gift` TINYINT NOT NULL DEFAULT 0 COMMENT '是否赠送卡密:0-收费,1-赠送' AFTER `status`;
+
+ALTER TABLE `cards_his`
+  ADD COLUMN `is_gift` TINYINT NOT NULL DEFAULT 0 COMMENT '是否赠送卡密:0-收费,1-赠送' AFTER `status`;

+ 6 - 0
backend-admin/migrations/20260411_add_cards_remark.sql

@@ -0,0 +1,6 @@
+-- 卡密备注(管理员可见)
+ALTER TABLE `cards`
+  ADD COLUMN `remark` VARCHAR(500) NULL DEFAULT NULL COMMENT '备注' AFTER `is_gift`;
+
+ALTER TABLE `cards_his`
+  ADD COLUMN `remark` VARCHAR(500) NULL DEFAULT NULL COMMENT '备注' AFTER `is_gift`;

+ 9 - 0
backend-admin/migrations/20260415_add_cards_is_extracted.sql

@@ -0,0 +1,9 @@
+-- 为 cards 与 cards_his 表增加提取状态字段
+ALTER TABLE cards
+    ADD COLUMN is_extracted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已提取:0-未提取,1-已提取' AFTER is_gift;
+
+ALTER TABLE cards
+    ADD INDEX idx_cards_is_extracted (is_extracted);
+
+ALTER TABLE cards_his
+    ADD COLUMN is_extracted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已提取:0-未提取,1-已提取' AFTER is_gift;

+ 18 - 0
backend-admin/migrations/20260423_add_cards_issue_fields.sql

@@ -0,0 +1,18 @@
+-- 为 cards 与 cards_his 表增加发放信息字段(价格/对象/备注/时间/管理员)
+ALTER TABLE cards
+    ADD COLUMN issue_price DECIMAL(10,2) NULL COMMENT '发放价格' AFTER is_extracted,
+    ADD COLUMN issued_to VARCHAR(200) NULL COMMENT '发放给谁(文本)' AFTER issue_price,
+    ADD COLUMN issue_remark VARCHAR(500) NULL COMMENT '发放备注' AFTER issued_to,
+    ADD COLUMN issued_at DATETIME NULL COMMENT '发放时间' AFTER issue_remark,
+    ADD COLUMN issued_admin_id INT NULL COMMENT '发放管理员ID' AFTER issued_at;
+
+ALTER TABLE cards
+    ADD INDEX idx_cards_issued_at (issued_at),
+    ADD INDEX idx_cards_issued_admin_id (issued_admin_id);
+
+ALTER TABLE cards_his
+    ADD COLUMN issue_price DECIMAL(10,2) NULL COMMENT '发放价格' AFTER is_extracted,
+    ADD COLUMN issued_to VARCHAR(200) NULL COMMENT '发放给谁(文本)' AFTER issue_price,
+    ADD COLUMN issue_remark VARCHAR(500) NULL COMMENT '发放备注' AFTER issued_to,
+    ADD COLUMN issued_at DATETIME NULL COMMENT '发放时间' AFTER issue_remark,
+    ADD COLUMN issued_admin_id INT NULL COMMENT '发放管理员ID' AFTER issued_at;

+ 18 - 0
backend-admin/migrations/20260423_add_cards_revoke_fields.sql

@@ -0,0 +1,18 @@
+-- 为 cards 与 cards_his 表增加“注销”字段:用于标记退回/作废的卡密不可再使用
+ALTER TABLE cards
+    ADD COLUMN is_revoked TINYINT NOT NULL DEFAULT 0 COMMENT '是否已注销:0-否,1-是' AFTER is_deleted,
+    ADD COLUMN revoked_at DATETIME NULL COMMENT '注销时间' AFTER is_revoked,
+    ADD COLUMN revoked_reason VARCHAR(500) NULL COMMENT '注销原因' AFTER revoked_at,
+    ADD COLUMN revoked_admin_id INT NULL COMMENT '注销管理员ID' AFTER revoked_reason;
+
+ALTER TABLE cards
+    ADD INDEX idx_cards_is_revoked (is_revoked),
+    ADD INDEX idx_cards_revoked_at (revoked_at),
+    ADD INDEX idx_cards_revoked_admin_id (revoked_admin_id);
+
+ALTER TABLE cards_his
+    ADD COLUMN is_revoked TINYINT NOT NULL DEFAULT 0 COMMENT '是否已注销:0-否,1-是' AFTER is_deleted,
+    ADD COLUMN revoked_at DATETIME NULL COMMENT '注销时间' AFTER is_revoked,
+    ADD COLUMN revoked_reason VARCHAR(500) NULL COMMENT '注销原因' AFTER revoked_at,
+    ADD COLUMN revoked_admin_id INT NULL COMMENT '注销管理员ID' AFTER revoked_reason;
+

+ 6 - 0
backend-admin/migrations/20260928_add_cards_special_close.sql

@@ -0,0 +1,6 @@
+-- 卡密:特殊关闭标识(0-关闭,1-打开,默认 0)
+ALTER TABLE `cards`
+  ADD COLUMN `special_close` TINYINT NOT NULL DEFAULT 0 COMMENT '特殊关闭:0-关闭,1-打开' AFTER `is_extracted`;
+
+ALTER TABLE `cards_his`
+  ADD COLUMN `special_close` TINYINT NOT NULL DEFAULT 0 COMMENT '特殊关闭:0-关闭,1-打开' AFTER `is_extracted`;

+ 16 - 0
backend-admin/migrations/20260929_add_software_buff_xp_steam_qg.sql

@@ -0,0 +1,16 @@
+-- 大客户版(Steam求购 / SteamQG)独立软件特征码
+-- 与普通版 buff-xp-steam-sh 隔离:发卡时选择本软件,客户端配置同名 code
+INSERT INTO `software` (`code`, `name`, `description`, `daily_price`, `is_active`, `is_deleted`, `created_at`, `updated_at`)
+SELECT
+  'buff-xp-steam-qg',
+  'Steam求购(大客户)',
+  '大客户专用客户端 SteamQG,与普通版 buff-xp-steam-sh 卡密互不通用',
+  1.00,
+  1,
+  0,
+  NOW(),
+  NOW()
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM `software` WHERE `code` = 'buff-xp-steam-qg'
+);

+ 2 - 2
backend-admin/requirements.txt

@@ -1,5 +1,5 @@
-# Web框架
-fastapi>=0.104.1
+# Web框架(需 FastAPI >=0.115.12 以兼容 Starlette 1.0+)
+fastapi>=0.115.12,<0.137.0
 uvicorn[standard]>=0.24.0
 uvicorn[standard]>=0.24.0
 
 
 # 数据库
 # 数据库

+ 21 - 8
backend-admin/start.sh

@@ -1,6 +1,6 @@
 #!/bin/bash
 #!/bin/bash
 
 
-# Steam求购管理端后端启动脚本
+# 游铛铛管理端后端启动脚本
 
 
 # 设置脚本遇到错误时退出
 # 设置脚本遇到错误时退出
 set -e
 set -e
@@ -11,7 +11,7 @@ GREEN='\033[0;32m'
 YELLOW='\033[1;33m'
 YELLOW='\033[1;33m'
 NC='\033[0m' # No Color
 NC='\033[0m' # No Color
 
 
-echo -e "${GREEN}Steam求购管理端后端启动脚本${NC}"
+echo -e "${GREEN}游铛铛管理端后端启动脚本${NC}"
 
 
 # 检查Python是否安装
 # 检查Python是否安装
 if ! command -v python3 &> /dev/null; then
 if ! command -v python3 &> /dev/null; then
@@ -25,10 +25,23 @@ if [ ! -f "app/main.py" ]; then
     exit 1
     exit 1
 fi
 fi
 
 
-# 检查依赖是否已安装
-if [ ! -d "venv" ] && [ ! -d ".venv" ]; then
-    echo -e "${YELLOW}警告:未找到虚拟环境,建议先创建虚拟环境并安装依赖${NC}"
-    echo -e "${YELLOW}运行:python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt${NC}"
+# 优先使用项目虚拟环境,避免与全局 Anaconda 包版本冲突
+if [ -d "venv/bin" ]; then
+    VENV_DIR="venv"
+elif [ -d ".venv/bin" ]; then
+    VENV_DIR=".venv"
+else
+    echo -e "${YELLOW}警告:未找到虚拟环境,将使用系统 Python(可能与 Starlette/FastAPI 版本不兼容)${NC}"
+    echo -e "${YELLOW}建议先运行:python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt${NC}"
+    VENV_DIR=""
+fi
+
+if [ -n "$VENV_DIR" ]; then
+    PYTHON_BIN="$VENV_DIR/bin/python"
+    UVICORN_BIN="$VENV_DIR/bin/uvicorn"
+else
+    PYTHON_BIN="python3"
+    UVICORN_BIN="uvicorn"
 fi
 fi
 
 
 # 解析命令行参数
 # 解析命令行参数
@@ -45,9 +58,9 @@ echo -e "${GREEN}启动模式:$MODE${NC}"
 if [ "$MODE" = "dev" ]; then
 if [ "$MODE" = "dev" ]; then
     echo -e "${GREEN}启动管理端开发服务器...${NC}"
     echo -e "${GREEN}启动管理端开发服务器...${NC}"
     echo -e "${YELLOW}API文档:http://localhost:9999/docs${NC}"
     echo -e "${YELLOW}API文档:http://localhost:9999/docs${NC}"
-    uvicorn app.main:app --reload --host 0.0.0.0 --port 9999
+    "$UVICORN_BIN" app.main:app --reload --host 0.0.0.0 --port 9999
 else
 else
     echo -e "${GREEN}启动管理端生产服务器...${NC}"
     echo -e "${GREEN}启动管理端生产服务器...${NC}"
     echo -e "${YELLOW}API文档:http://localhost:9999/docs${NC}"
     echo -e "${YELLOW}API文档:http://localhost:9999/docs${NC}"
-    uvicorn app.main:app --host 0.0.0.0 --port 9999 --workers 4
+    "$UVICORN_BIN" app.main:app --host 0.0.0.0 --port 9999 --workers 4
 fi
 fi

+ 71 - 0
backend-admin/start_prod.sh

@@ -0,0 +1,71 @@
+#!/bin/sh
+# 游铛铛管理端后端 — 生产环境启动脚本
+# 虚拟环境选择顺序(与当前 shell 里 echo $VIRTUAL_ENV 无关,以实际 python 路径为准):
+#   1) 环境变量 VENV_DIR(显式指定,如 /root/sync_goods/venv)
+#   2) 与 backend-admin 同级的 sync_goods/venv(常见生产布局:~/backend-admin + ~/sync_goods/venv)
+#   3) 本目录 venv 或 .venv
+# 兼容 /bin/sh(dash)
+
+set -e
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
+cd "$SCRIPT_DIR"
+
+printf '%b\n' "${GREEN}游铛铛管理端后端(生产)${NC}"
+
+if [ ! -f "app/main.py" ]; then
+    printf '%b\n' "${RED}错误:未找到 app/main.py,脚本应位于 backend-admin 目录${NC}"
+    exit 1
+fi
+
+SYNC_GOODS_VENV="$SCRIPT_DIR/../sync_goods/venv"
+
+if [ -n "${VENV_DIR:-}" ]; then
+    if [ ! -f "$VENV_DIR/bin/python" ]; then
+        printf '%b\n' "${RED}错误:VENV_DIR=$VENV_DIR 下未找到 bin/python${NC}"
+        exit 1
+    fi
+    VENV_ROOT=$(CDPATH= cd -- "$VENV_DIR" && pwd)
+elif [ -f "$SYNC_GOODS_VENV/bin/python" ]; then
+    VENV_ROOT=$(CDPATH= cd -- "$SYNC_GOODS_VENV" && pwd)
+elif [ -f "venv/bin/python" ]; then
+    VENV_ROOT="$SCRIPT_DIR/venv"
+elif [ -f ".venv/bin/python" ]; then
+    VENV_ROOT="$SCRIPT_DIR/.venv"
+else
+    printf '%b\n' "${RED}错误:未找到可用的虚拟环境${NC}"
+    printf '%b\n' "${YELLOW}已按顺序查找: \$VENV_DIR、$SYNC_GOODS_VENV、$SCRIPT_DIR/venv、$SCRIPT_DIR/.venv${NC}"
+    printf '%b\n' "${YELLOW}请指定:export VENV_DIR=/你的/venv路径  再执行本脚本${NC}"
+    exit 1
+fi
+
+PYTHON_BIN="$VENV_ROOT/bin/python"
+printf '%b\n' "${GREEN}使用虚拟环境: $VENV_ROOT${NC}"
+
+if ! "$PYTHON_BIN" -c "import uvicorn" > /dev/null 2>&1; then
+    if [ ! -f requirements.txt ]; then
+        printf '%b\n' "${RED}错误:缺少 requirements.txt,无法安装依赖${NC}"
+        exit 1
+    fi
+    if [ "${START_PROD_NO_AUTO_PIP:-}" = "1" ]; then
+        printf '%b\n' "${RED}错误:venv 中未安装 uvicorn,且已设置 START_PROD_NO_AUTO_PIP=1 跳过自动安装${NC}"
+        printf '%b\n' "${YELLOW}请执行: $PYTHON_BIN -m pip install -r $SCRIPT_DIR/requirements.txt${NC}"
+        exit 1
+    fi
+    printf '%b\n' "${YELLOW}未检测到 uvicorn,正在执行: $PYTHON_BIN -m pip install -r requirements.txt${NC}"
+    "$PYTHON_BIN" -m pip install -r requirements.txt
+fi
+
+if ! "$PYTHON_BIN" -c "import uvicorn" > /dev/null 2>&1; then
+    printf '%b\n' "${RED}错误:安装依赖后仍无法 import uvicorn${NC}"
+    exit 1
+fi
+
+printf '%b\n' "${GREEN}启动管理端生产服务器...${NC}"
+printf '%b\n' "${YELLOW}API 文档:http://localhost:9999/docs${NC}"
+exec "$PYTHON_BIN" -m uvicorn app.main:app --host 0.0.0.0 --port 9999 --workers 4

+ 16 - 0
backend-client/migrations/20260929_add_software_buff_xp_steam_qg.sql

@@ -0,0 +1,16 @@
+-- 大客户版(Steam求购 / SteamQG)独立软件特征码
+-- 与普通版 buff-xp-steam-sh 隔离:发卡时选择本软件,客户端配置同名 code
+INSERT INTO `software` (`code`, `name`, `description`, `daily_price`, `is_active`, `is_deleted`, `created_at`, `updated_at`)
+SELECT
+  'buff-xp-steam-qg',
+  'Steam求购(大客户)',
+  '大客户专用客户端 SteamQG,与普通版 buff-xp-steam-sh 卡密互不通用',
+  1.00,
+  1,
+  0,
+  NOW(),
+  NOW()
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM `software` WHERE `code` = 'buff-xp-steam-qg'
+);

+ 3 - 3
client-tauri/README.md

@@ -46,7 +46,7 @@ npm run tauri:dev
   "api": {
   "api": {
     "base_url": "https://your-api-server.com",
     "base_url": "https://your-api-server.com",
     "timeout": 30,
     "timeout": 30,
-    "software_code": "buff-xp-steam-sh"
+    "software_code": "buff-xp-steam-qg"
   },
   },
   "security": {
   "security": {
     "encryption_enabled": true,
     "encryption_enabled": true,
@@ -108,7 +108,7 @@ npm run tauri:dev
   "api": {
   "api": {
     "base_url": "http://localhost:8000",
     "base_url": "http://localhost:8000",
     "timeout": 30,
     "timeout": 30,
-    "software_code": "buff-xp-steam-sh"
+    "software_code": "buff-xp-steam-qg"
   },
   },
   "security": {
   "security": {
     "encryption_enabled": true,
     "encryption_enabled": true,
@@ -121,7 +121,7 @@ npm run tauri:dev
 #### 配置说明
 #### 配置说明
 - **base_url**: 后端API服务器地址
 - **base_url**: 后端API服务器地址
 - **timeout**: API请求超时时间(秒)
 - **timeout**: API请求超时时间(秒)
-- **software_code**: 软件标识码(与后端约定)
+- **software_code**: 大客户版固定为 `buff-xp-steam-qg`(与普通版 `buff-xp-steam-sh` 隔离,设置页不可改)
 - **encryption_enabled**: 是否启用数据加密
 - **encryption_enabled**: 是否启用数据加密
 - **key_rotation_days**: 密钥轮换周期(天)
 - **key_rotation_days**: 密钥轮换周期(天)
 - **heartbeat_interval**: 心跳检测间隔(秒)
 - **heartbeat_interval**: 心跳检测间隔(秒)

BIN
client-tauri/SteamQG.exe


BIN
client-tauri/SteamQG_Portable.zip


BIN
client-tauri/Steam求购.exe


+ 1 - 1
client-tauri/config.example.json

@@ -2,7 +2,7 @@
   "api": {
   "api": {
     "base_url": "http://192.168.2.105:8000",
     "base_url": "http://192.168.2.105:8000",
     "timeout": 30,
     "timeout": 30,
-    "software_code": "buff-xp-steam-sh"
+    "software_code": "buff-xp-steam-qg"
   },
   },
   "security": {
   "security": {
     "encryption_enabled": true,
     "encryption_enabled": true,

+ 1 - 1
client-tauri/default-config.json

@@ -2,7 +2,7 @@
   "api": {
   "api": {
     "base_url": "http://125.122.32.114:9000",
     "base_url": "http://125.122.32.114:9000",
     "timeout": 30,
     "timeout": 30,
-    "software_code": "buff-xp-steam-sh"
+    "software_code": "buff-xp-steam-qg"
   },
   },
   "security": {
   "security": {
     "encryption_enabled": true,
     "encryption_enabled": true,

+ 1 - 1
client-tauri/src-tauri/src/api_client.rs

@@ -28,7 +28,7 @@ impl Default for ApiConfig {
         ApiConfig {
         ApiConfig {
             base_url: "http://125.122.32.114:9000".to_string(),
             base_url: "http://125.122.32.114:9000".to_string(),
             timeout: 30,
             timeout: 30,
-            software_code: "buff-xp-steam-sh".to_string(),
+            software_code: "buff-xp-steam-qg".to_string(),
         }
         }
     }
     }
 }
 }

+ 12 - 1
client-tauri/src-tauri/src/config.rs

@@ -53,7 +53,8 @@ impl Default for AppConfig {
                 // 与 backend-client 默认端口 9000 一致
                 // 与 backend-client 默认端口 9000 一致
                 base_url: packaged_default_backend_api_url(),
                 base_url: packaged_default_backend_api_url(),
                 timeout: 30,
                 timeout: 30,
-                software_code: "buff-xp-steam-sh".to_string(),
+                // 大客户版(SteamQG)固定软件码,与普通版 buff-xp-steam-sh 隔离
+                software_code: "buff-xp-steam-qg".to_string(),
             },
             },
             security: SecurityConfig {
             security: SecurityConfig {
                 encryption_enabled: true,
                 encryption_enabled: true,
@@ -71,6 +72,7 @@ impl AppConfig {
         if config_path.exists() {
         if config_path.exists() {
             let config_content = fs::read_to_string(&config_path)?;
             let config_content = fs::read_to_string(&config_path)?;
             let mut config: AppConfig = serde_json::from_str(&config_content)?;
             let mut config: AppConfig = serde_json::from_str(&config_content)?;
+            let mut need_save = false;
             // 兼容旧配置:曾用 8000 端口的,自动迁移到 9000 端口(保留原有 IP 地址形式)
             // 兼容旧配置:曾用 8000 端口的,自动迁移到 9000 端口(保留原有 IP 地址形式)
             if config.api.base_url == "http://localhost:8000"
             if config.api.base_url == "http://localhost:8000"
                 || config.api.base_url == "http://127.0.0.1:8000"
                 || config.api.base_url == "http://127.0.0.1:8000"
@@ -81,6 +83,15 @@ impl AppConfig {
                 } else {
                 } else {
                     config.api.base_url = "http://125.122.32.114:9000".to_string();
                     config.api.base_url = "http://125.122.32.114:9000".to_string();
                 }
                 }
+                need_save = true;
+            }
+            // 大客户版强制 software_code,避免本地旧配置仍为普通版 sh 码
+            const DAKEHU_SOFTWARE_CODE: &str = "buff-xp-steam-qg";
+            if config.api.software_code.trim() != DAKEHU_SOFTWARE_CODE {
+                config.api.software_code = DAKEHU_SOFTWARE_CODE.to_string();
+                need_save = true;
+            }
+            if need_save {
                 let _ = config.save();
                 let _ = config.save();
             }
             }
             Ok(config)
             Ok(config)

+ 5 - 4
client-tauri/src/views/BuffSelectionView.vue

@@ -3103,6 +3103,7 @@ function shouldRunAutoSelectionNow(now: Dayjs): boolean {
 function startAutoSelectionScheduler() {
 function startAutoSelectionScheduler() {
   stopAutoSelectionScheduler()
   stopAutoSelectionScheduler()
   autoSelectionTimer.value = setInterval(() => {
   autoSelectionTimer.value = setInterval(() => {
+    if (authStore.isSpecialClosed) return
     if (!initialLoadDone.value) return
     if (!initialLoadDone.value) return
     if (running.value) return
     if (running.value) return
     const now = dayjs()
     const now = dayjs()
@@ -3166,8 +3167,8 @@ async function prepareBuffEnvironmentBeforeStart(
 }
 }
 
 
 async function handleStart(source: 'manual' | 'auto' = 'manual') {
 async function handleStart(source: 'manual' | 'auto' = 'manual') {
-  // 特殊关闭:仅「开始选品」手动点击无效果(自动选品不受影响)
-  if (authStore.isSpecialClosed && source === 'manual') return
+  // 特殊关闭:手动「开始选品」与定时自动选品均不执行
+  if (authStore.isSpecialClosed) return
   // 防止模板直接绑定函数时 click 事件对象误传入,默认按手动触发处理
   // 防止模板直接绑定函数时 click 事件对象误传入,默认按手动触发处理
   if (source !== 'manual' && source !== 'auto') {
   if (source !== 'manual' && source !== 'auto') {
     source = 'manual'
     source = 'manual'
@@ -3413,8 +3414,8 @@ async function handleBatchImportSelectionItems() {
 }
 }
 
 
 async function handleStartSweep(isAuto: boolean, sourceSelectionRequestId?: number | null) {
 async function handleStartSweep(isAuto: boolean, sourceSelectionRequestId?: number | null) {
-  // 特殊关闭:仅「开始扫货」手动点击无效果(自动扫货不受影响)
-  if (authStore.isSpecialClosed && !isAuto) return
+  // 特殊关闭:手动「开始扫货」与选品成功后自动扫货均不执行
+  if (authStore.isSpecialClosed) return
   const sessionId = authStore.sessionId
   const sessionId = authStore.sessionId
   const machineCode = authStore.machineCode
   const machineCode = authStore.machineCode
   if (!sessionId || !machineCode) {
   if (!sessionId || !machineCode) {

+ 7 - 5
client-tauri/src/views/SettingsView.vue

@@ -27,10 +27,10 @@
           <label class="form-label">软件特征码</label>
           <label class="form-label">软件特征码</label>
           <a-input
           <a-input
             v-model:value="apiSettings.softwareCode"
             v-model:value="apiSettings.softwareCode"
-            placeholder="请输入软件特征码"
-            :disabled="saving"
+            placeholder="软件特征码"
+            disabled
           />
           />
-          <div class="form-hint">默认: buff-xp-steam-sh</div>
+          <div class="form-hint">大客户版固定: buff-xp-steam-qg(不可修改)</div>
         </div>
         </div>
 
 
         <div class="form-group">
         <div class="form-group">
@@ -175,9 +175,11 @@ const updateVersion = ref('')
 const updateTitle = ref('')
 const updateTitle = ref('')
 
 
 // API设置
 // API设置
+const DAKEHU_SOFTWARE_CODE = 'buff-xp-steam-qg'
+
 const apiSettings = reactive({
 const apiSettings = reactive({
   baseUrl: 'http://125.122.32.114:9000',
   baseUrl: 'http://125.122.32.114:9000',
-  softwareCode: 'buff-xp-steam-sh',
+  softwareCode: DAKEHU_SOFTWARE_CODE,
   timeout: 30
   timeout: 30
 })
 })
 
 
@@ -224,7 +226,7 @@ async function saveSettings() {
 // 重置设置
 // 重置设置
 function resetSettings() {
 function resetSettings() {
   apiSettings.baseUrl = 'http://125.122.32.114:9000'
   apiSettings.baseUrl = 'http://125.122.32.114:9000'
-  apiSettings.softwareCode = 'buff-xp-steam-sh'
+  apiSettings.softwareCode = DAKEHU_SOFTWARE_CODE
   apiSettings.timeout = 30
   apiSettings.timeout = 30
   securitySettings.autoRenewSession = true
   securitySettings.autoRenewSession = true
   securitySettings.enableHeartbeat = true
   securitySettings.enableHeartbeat = true