Quellcode durchsuchen

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong vor 1 Monat
Ursprung
Commit
540a3fc552
46 geänderte Dateien mit 1823 neuen und 130 gelöschten Zeilen
  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" />
     <link rel="icon" type="image/svg+xml" href="/vite.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Steam求购管理端</title>
+    <title>游铛铛管理端</title>
   </head>
   <body>
     <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",
   "version": "1.0.0",
-  "description": "Steam求购管理端",
+  "description": "游铛铛管理端",
   "type": "module",
   "scripts": {
     "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),
   getList: (params?: any) => apiClient.get('/admin/cards', { params }),
   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) =>
     apiClient.get(`/admin/cards/${id}/activate`),
   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 }}>
           <img
             src="/logo.png"
-            alt="Steam求购"
+            alt="游铛铛"
             style={{
               height: '48px',
               width: 'auto',
             }}
           />
           <span style={{ fontSize: 18, fontWeight: 600, color: 'rgba(0, 0, 0, 0.85)' }}>
-            Steam求购管理端
+            游铛铛管理端
           </span>
         </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 { Resizable } from 'react-resizable'
 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 { handleAxis, ...restProps } = props
@@ -48,11 +197,14 @@ const ResizableTitle = (props: any) => {
 interface ResizableTableProps<T> extends TableProps<T> {
   columns: ColumnsType<T>
   actionColumnKey?: string // 操作列的 key,用于固定到右侧
+  /** 自适应容器宽度,不启用横向滚动 */
+  fitViewport?: boolean
 }
 
 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 wrapperRef = useRef<HTMLDivElement>(null)
 
   useEffect(() => {
     setColumns(initialColumns)
@@ -74,8 +226,12 @@ export default function ResizableTable<T extends object>(props: ResizableTablePr
     if (actionColumnKey && col.key === actionColumnKey) {
       return {
         ...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)
+  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 (
-    <div style={{ overflow: 'auto' }}>
+    <div
+      className={`resizable-table-wrapper${fitViewport ? ' resizable-table-fit-viewport' : ''}`}
+      ref={wrapperRef}
+    >
       <Table
         {...tableProps}
+        tableLayout={fitViewport ? 'auto' : tableProps.tableLayout}
         columns={resizableColumns}
         components={{
           header: {
             cell: ResizableTitle,
           },
         }}
-        scroll={{
-          x: minWidth > 800 ? minWidth : 800, // 当总宽度超过 800px 时启用横向滚动
-          y: tableProps.scroll?.y,
-        }}
+        scroll={tableScroll}
       />
+      {!fitViewport && (
+        <TableHorizontalScrollbar wrapperRef={wrapperRef} refreshKey={scrollRefreshKey} />
+      )}
     </div>
   )
 }

+ 58 - 0
admin/src/index.css

@@ -98,6 +98,64 @@ body {
   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-password,

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

@@ -13,7 +13,6 @@ import {
   Tag,
   Card,
 } from 'antd'
-import { EyeOutlined } from '@ant-design/icons'
 import { announcementApi } from '../../api/admin'
 import type { ColumnsType } from 'antd/es/table'
 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 {
   Button,
   message,
@@ -7,12 +7,14 @@ import {
   Select,
   InputNumber,
   Input,
+  Tooltip,
   Space,
   Tag,
   Descriptions,
   Card,
+  Radio,
 } from 'antd'
-import { SearchOutlined, EyeOutlined, DownloadOutlined } from '@ant-design/icons'
+import { SearchOutlined } from '@ant-design/icons'
 import { cardApi, softwareApi } from '../../api/admin'
 import dayjs from 'dayjs'
 import { formatDate } from '../../utils/formatDate'
@@ -27,6 +29,23 @@ interface Card {
   days: number
   discount: 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
   username: string | null
   activated_at: string | null
@@ -41,56 +60,148 @@ export default function Cards() {
   const [softwareList, setSoftwareList] = useState<any[]>([])
   const [modalVisible, setModalVisible] = useState(false)
   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 [softwareFilter, setSoftwareFilter] = useState<string | 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 [detailData, setDetailData] = useState<any>(null)
   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> = [
     {
-      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: '卡密',
       dataIndex: '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: '软件名称',
       dataIndex: 'software_name',
       key: 'software_name',
-      width: 120,
+      width: 88,
+      ellipsis: { showTitle: false },
+      render: (text: string) => renderEllipsis(text),
     },
     {
       title: '天数',
       dataIndex: '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: '状态',
       dataIndex: 'status',
       key: 'status',
-      width: 100,
+      width: 64,
+      align: 'center',
       render: (status) => {
         const statusMap: Record<number, { text: string; color: string }> = {
           0: { text: '未激活', color: 'default' },
@@ -101,60 +212,63 @@ export default function Cards() {
         return <Tag color={statusInfo.color}>{statusInfo.text}</Tag>
       },
     },
-    {
-      title: '激活用户ID',
-      dataIndex: 'user_id',
-      key: 'user_id',
-      width: 120,
-      render: (text: number) => text || '-',
-    },
     {
       title: '用户名称',
       dataIndex: 'username',
       key: 'username',
-      width: 120,
-      render: (text: string) => text || '-',
+      width: 80,
+      ellipsis: { showTitle: false },
+      render: (text: string) => renderEllipsis(text),
     },
     {
       title: '激活时间',
       dataIndex: 'activated_at',
       key: 'activated_at',
-      width: 160,
-      render: (text: string) => formatDate(text),
+      width: 104,
+      ellipsis: { showTitle: false },
+      render: (text: string) => renderDateCell(text),
     },
     {
       title: '到期时间',
       dataIndex: '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: '操作',
       key: 'action',
-      width: 100,
+      width: 168,
       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) {
         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)
       if (response.code === 200) {
         setData(response.data || [])
+        setSelectedRowKeys([])
         setPagination({
           ...pagination,
           total: response.total || 0,
@@ -211,7 +338,16 @@ export default function Cards() {
   useEffect(() => {
     fetchData()
     fetchSoftwareList()
-  }, [pagination.current, pagination.pageSize, softwareFilter, statusFilter])
+  }, [
+    pagination.current,
+    pagination.pageSize,
+    softwareFilter,
+    statusFilter,
+    isGiftFilter,
+    isExtractedFilter,
+    specialCloseFilter,
+    isRevokedFilter,
+  ])
 
   const handleSearch = () => {
     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 () => {
     try {
       const params: any = {}
@@ -259,6 +578,18 @@ export default function Cards() {
       if (statusFilter !== undefined) {
         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)
 
@@ -361,7 +692,7 @@ export default function Cards() {
         >
         <Space size="middle" wrap>
           <Input
-            placeholder="搜索卡密代码"
+            placeholder="搜索卡密代码或备注"
             value={keyword}
             onChange={(e) => setKeyword(e.target.value)}
             onPressEnter={handleSearch}
@@ -392,6 +723,46 @@ export default function Cards() {
             <Select.Option value={1}>已激活</Select.Option>
             <Select.Option value={2}>已过期</Select.Option>
           </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>
@@ -400,6 +771,10 @@ export default function Cards() {
               setKeyword('')
               setSoftwareFilter(undefined)
               setStatusFilter(undefined)
+              setIsGiftFilter(undefined)
+              setIsExtractedFilter(undefined)
+              setSpecialCloseFilter(undefined)
+              setIsRevokedFilter(undefined)
               setPagination({ ...pagination, current: 1 })
             }}
           >
@@ -417,6 +792,27 @@ export default function Cards() {
             <Button type="primary" onClick={() => setModalVisible(true)}>
               生成卡密
             </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}>
               导出Excel
             </Button>
@@ -428,7 +824,13 @@ export default function Cards() {
         dataSource={data}
         loading={loading}
         rowKey="id"
+        size="small"
+        fitViewport
         actionColumnKey="action"
+        rowSelection={{
+          selectedRowKeys,
+          onChange: (keys) => setSelectedRowKeys(keys),
+        }}
         pagination={{
           current: pagination.current,
           pageSize: pagination.pageSize,
@@ -470,6 +872,30 @@ export default function Cards() {
               <Descriptions.Item label="折扣">
                 {(detailData.discount * 100).toFixed(0)}%
               </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="状态">
                 {detailData.status === 0 && <Tag color="default">未激活</Tag>}
                 {detailData.status === 1 && <Tag color="green">已激活</Tag>}
@@ -509,8 +935,18 @@ export default function Cards() {
         }}
         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>
               {softwareList.map((item) => (
                 <Select.Option key={item.code} value={item.code}>
@@ -522,12 +958,86 @@ export default function Cards() {
           <Form.Item name="days" label="天数" rules={[{ required: true }]}>
             <InputNumber min={1} style={{ width: '100%' }} />
           </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 name="count" label="生成数量" rules={[{ required: true }]}>
             <InputNumber min={1} style={{ width: '100%' }} />
           </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>
       </Modal>
     </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'
 
 interface StatsData {
+  heartbeat: {
+    today_users: number
+    today_sessions: number
+  }
   users: {
     total: number
     today_new: number
@@ -99,6 +103,20 @@ export default function Dashboard() {
       <div className="page-title">仪表盘</div>
       <div className="page-subtitle">系统数据概览</div>
       <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}>
           <Card>
             <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)
     setKeyViewModalVisible(true)
   }

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

@@ -4,7 +4,7 @@
   display: flex;
   align-items: 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-size: cover;
   background-position: center;
@@ -41,7 +41,7 @@
 }
 
 .card-title-logo {
-  width: auto;
+  width: 60px;
   height: 60px;
   object-fit: contain;
   display: inline-block;

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

@@ -47,8 +47,8 @@ export default function Login() {
         className="login-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>
         }
       >

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

@@ -11,7 +11,7 @@ import {
   Tag,
   Card,
 } from 'antd'
-import { SearchOutlined, EyeOutlined, DownloadOutlined } from '@ant-design/icons'
+import { SearchOutlined } from '@ant-design/icons'
 import { userApi, softwareApi } from '../../api/admin'
 import dayjs from 'dayjs'
 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
+# 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
 ```
 
@@ -56,3 +61,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 9999
 启动服务后访问:
 - Swagger UI: http://localhost:9999/docs
 - 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"

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

@@ -1,10 +1,13 @@
 """
 卡密管理接口
 """
+import logging
 from fastapi import APIRouter, Depends, Query, Request
 from fastapi.responses import StreamingResponse
 from sqlalchemy.orm import Session
-from pydantic import BaseModel
+from typing import Optional
+
+from pydantic import BaseModel, Field
 import secrets
 import string
 import csv
@@ -15,6 +18,8 @@ from app.models.card import Card
 from datetime import datetime
 from app.utils.timezone import beijing_now
 
+logger = logging.getLogger(__name__)
+
 router = APIRouter()
 
 class CardGenerateRequest(BaseModel):
@@ -23,6 +28,39 @@ class CardGenerateRequest(BaseModel):
     days: int
     discount: float
     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:
     """生成卡密代码"""
@@ -38,6 +76,8 @@ async def generate_cards(
 ):
     """生成卡密"""
     try:
+        remark_clean = (req.remark or "").strip() or None
+
         cards = []
         for _ in range(req.count):
             card_code = generate_card_code()
@@ -51,6 +91,8 @@ async def generate_cards(
                 days=req.days,
                 discount=req.discount,
                 status=0,
+                is_gift=req.is_gift,
+                remark=remark_clean,
                 created_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
+        gift_label = "赠送" if req.is_gift == 1 else "收费"
         create_operation_log(
             db=db,
             admin_id=current_admin.id,
             module="卡密管理",
             action="生成卡密",
-            description=f"批量生成卡密:{req.count}张,软件:{req.software_code},天数:{req.days}天",
+            description=f"批量生成卡密:{req.count}张({gift_label}),软件:{req.software_code},天数:{req.days}天",
             new_data={
                 "software_code": req.software_code,
                 "days": req.days,
                 "discount": req.discount,
                 "count": req.count,
+                "is_gift": req.is_gift,
+                "remark": remark_clean,
                 "card_codes": [c.card_code for c in cards]
             },
             ip_address=client_ip
@@ -108,14 +153,19 @@ async def generate_cards(
 async def get_cards(
     page: int = Query(1, ge=1),
     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="软件特征码筛选"),
     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),
     current_admin = Depends(get_current_admin)
 ):
     """获取卡密列表"""
     from sqlalchemy import or_
+
     from app.models.software import Software
     from app.models.user import User
 
@@ -129,7 +179,10 @@ async def get_cards(
 
     # 搜索
     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:
@@ -138,8 +191,20 @@ async def get_cards(
     if status is not None:
         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()
     total = query.count()
@@ -156,6 +221,19 @@ async def get_cards(
                 "days": c.days,
                 "discount": float(c.discount),
                 "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,
                 "username": username,
                 "activated_at": c.activated_at.isoformat() if c.activated_at else None,
@@ -170,6 +248,270 @@ async def get_cards(
         "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}")
 async def get_card_detail(
     card_id: int,
@@ -204,6 +546,19 @@ async def get_card_detail(
             "days": card.days,
             "discount": float(card.discount),
             "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_info": user_info,
             "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")
 async def get_card_activate_info(
     card_id: int,
@@ -231,7 +716,12 @@ async def get_card_activate_info(
             "card_code": card.card_code,
             "user_id": card.user_id,
             "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="搜索关键词"),
     software_code: str = 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),
     current_admin = Depends(get_current_admin)
 ):
     """导出卡密为Excel"""
+    from sqlalchemy import or_
+
     from app.models.software import Software
     from app.models.user import User
 
@@ -256,7 +752,10 @@ async def export_cards_excel(
 
     # 搜索
     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:
@@ -265,7 +764,19 @@ async def export_cards_excel(
     if status is not None:
         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
@@ -282,7 +793,11 @@ async def export_cards_excel(
             "filters": {
                 "keyword": keyword,
                 "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
@@ -293,11 +808,18 @@ async def export_cards_excel(
     output = io.StringIO()
     writer = csv.writer(output)
     writer.writerow([
-        'ID', '卡密代码', '软件特征码', '软件名称', '天数', '折扣', '状态',
+        'ID', '卡密代码', '软件特征码', '软件名称', '天数', '折扣', '状态', '卡密类型', '提取状态', '特殊关闭',
+        '注销状态', '注销时间', '注销原因', '注销管理员ID',
+        '发放价格', '发放对象', '发放备注', '发放时间', '发放管理员ID',
+        '备注',
         '激活用户ID', '用户名称', '激活时间', '到期时间', '创建时间', '更新时间'
     ])
 
     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:
         writer.writerow([
             card.id,
@@ -307,6 +829,19 @@ async def export_cards_excel(
             card.days,
             float(card.discount),
             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 '',
             username or '',
             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.card import Card
 from app.models.api_log import APILog
+from app.models.session import Session as UserSession
 from app.background_tasks import get_task_manager
 from datetime import datetime, timedelta
 from app.utils.timezone import beijing_now
@@ -104,6 +105,24 @@ async def get_dashboard_stats(
         }
         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()
@@ -118,6 +137,10 @@ async def get_dashboard_stats(
         "code": 200,
         "msg": "成功",
         "data": {
+            "heartbeat": {
+                "today_users": heartbeat_today_users,
+                "today_sessions": heartbeat_today_sessions
+            },
             "users": {
                 "total": total_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_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")
 
     # 数据库URL

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

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

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

@@ -18,8 +18,20 @@ class Card(Base):
     activated_at = Column(DateTime, comment="激活时间")
     expire_time = Column(DateTime, index=True, comment="到期时间")
     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-是")
-    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="创建时间")
     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="激活时间")
     expire_time = Column(DateTime, comment="到期时间")
     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_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="创建时间")
     updated_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,
         expire_time=card.expire_time,
         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_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,
         updated_at=card.updated_at,
         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
 
 # 数据库

+ 21 - 8
backend-admin/start.sh

@@ -1,6 +1,6 @@
 #!/bin/bash
 
-# Steam求购管理端后端启动脚本
+# 游铛铛管理端后端启动脚本
 
 # 设置脚本遇到错误时退出
 set -e
@@ -11,7 +11,7 @@ GREEN='\033[0;32m'
 YELLOW='\033[1;33m'
 NC='\033[0m' # No Color
 
-echo -e "${GREEN}Steam求购管理端后端启动脚本${NC}"
+echo -e "${GREEN}游铛铛管理端后端启动脚本${NC}"
 
 # 检查Python是否安装
 if ! command -v python3 &> /dev/null; then
@@ -25,10 +25,23 @@ if [ ! -f "app/main.py" ]; then
     exit 1
 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
 
 # 解析命令行参数
@@ -45,9 +58,9 @@ echo -e "${GREEN}启动模式:$MODE${NC}"
 if [ "$MODE" = "dev" ]; then
     echo -e "${GREEN}启动管理端开发服务器...${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
     echo -e "${GREEN}启动管理端生产服务器...${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

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

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

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

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

@@ -28,7 +28,7 @@ impl Default for ApiConfig {
         ApiConfig {
             base_url: "http://125.122.32.114:9000".to_string(),
             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 一致
                 base_url: packaged_default_backend_api_url(),
                 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 {
                 encryption_enabled: true,
@@ -71,6 +72,7 @@ impl AppConfig {
         if config_path.exists() {
             let config_content = fs::read_to_string(&config_path)?;
             let mut config: AppConfig = serde_json::from_str(&config_content)?;
+            let mut need_save = false;
             // 兼容旧配置:曾用 8000 端口的,自动迁移到 9000 端口(保留原有 IP 地址形式)
             if config.api.base_url == "http://localhost:8000"
                 || config.api.base_url == "http://127.0.0.1:8000"
@@ -81,6 +83,15 @@ impl AppConfig {
                 } else {
                     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();
             }
             Ok(config)

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

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

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

@@ -27,10 +27,10 @@
           <label class="form-label">软件特征码</label>
           <a-input
             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 class="form-group">
@@ -175,9 +175,11 @@ const updateVersion = ref('')
 const updateTitle = ref('')
 
 // API设置
+const DAKEHU_SOFTWARE_CODE = 'buff-xp-steam-qg'
+
 const apiSettings = reactive({
   baseUrl: 'http://125.122.32.114:9000',
-  softwareCode: 'buff-xp-steam-sh',
+  softwareCode: DAKEHU_SOFTWARE_CODE,
   timeout: 30
 })
 
@@ -224,7 +226,7 @@ async function saveSettings() {
 // 重置设置
 function resetSettings() {
   apiSettings.baseUrl = 'http://125.122.32.114:9000'
-  apiSettings.softwareCode = 'buff-xp-steam-sh'
+  apiSettings.softwareCode = DAKEHU_SOFTWARE_CODE
   apiSettings.timeout = 30
   securitySettings.autoRenewSession = true
   securitySettings.enableHeartbeat = true