ALIEZ 1 月之前
父节点
当前提交
cdee688a78

+ 2 - 0
.env.production

@@ -1,2 +1,4 @@
 # 线上环境(npm run build / build:prod)
 # 线上环境(npm run build / build:prod)
 VITE_API_BASE=https://ad.jinclab.com
 VITE_API_BASE=https://ad.jinclab.com
+# 静态站无 SPA fallback 时用 hash,刷新不会 404;若 Nginx 已配置 try_files 可改为 false
+VITE_USE_HASH_HISTORY=true

+ 10 - 2
deploy.sh

@@ -10,7 +10,7 @@ SERVER_HOST="47.83.117.213"
 SERVER_USER="root"
 SERVER_USER="root"
 SERVER_PASSWORD='Bkr!Srv#Mt5@Xoh_9KpL2'
 SERVER_PASSWORD='Bkr!Srv#Mt5@Xoh_9KpL2'
 REMOTE_ARCHIVE="/tmp/manager-service-font-dist.tar.gz"
 REMOTE_ARCHIVE="/tmp/manager-service-font-dist.tar.gz"
-REMOTE_DIR="/usr/local/golden/manager-service/font"
+REMOTE_DIR="/usr/local/golden/manager-service/front"
 LOCAL_ARCHIVE="dist.tar.gz"
 LOCAL_ARCHIVE="dist.tar.gz"
 
 
 if ! command -v expect >/dev/null 2>&1; then
 if ! command -v expect >/dev/null 2>&1; then
@@ -22,10 +22,18 @@ echo "==> Installing dependencies"
 npm install
 npm install
 
 
 echo "==> Building production bundle"
 echo "==> Building production bundle"
-# Pin production API origin (Vite does not override env vars already set).
+# Pin production env (Vite does not override env vars already set).
 export VITE_API_BASE="https://ad.jinclab.com"
 export VITE_API_BASE="https://ad.jinclab.com"
+export VITE_USE_HASH_HISTORY="true"
 npm run build
 npm run build
 
 
+echo "==> Verifying API base in bundle (no old API origin)"
+# 合法包内可能含主机名字面量(如 resolveMediaUrl 用于把旧图站迁到线上),只拦截「整段 URL / 旧端口」误打进 axios 等配置
+if [[ -d dist/assets ]] && grep -l -E 'https?://103\.158\.191\.66|103\.158\.191\.66:8505' dist/assets/*.js 2>/dev/null | grep -q .; then
+  echo "Error: dist still embeds old API origin (http(s)://103.158... or :8505). Check .env / build mode. Aborting deploy."
+  exit 1
+fi
+
 echo "==> Packaging dist/"
 echo "==> Packaging dist/"
 COPYFILE_DISABLE=1 tar -czf "${LOCAL_ARCHIVE}" dist
 COPYFILE_DISABLE=1 tar -czf "${LOCAL_ARCHIVE}" dist
 
 

二进制
dist.tar.gz


+ 3 - 26
src/api/modules/courses/goods.ts

@@ -1,6 +1,6 @@
 import type { AxiosHeaders } from 'axios'
 import type { AxiosHeaders } from 'axios'
-import { apiBase } from '@/config/env'
 import { axiosInstance, request } from '../../request'
 import { axiosInstance, request } from '../../request'
+import { resolveMediaUrl } from '@/utils/resolveMediaUrl'
 import { type PageParam, unwrapPageList } from '../../types'
 import { type PageParam, unwrapPageList } from '../../types'
 
 
 export type GoodsType = 1 | 2 | 3 | 4 | 5
 export type GoodsType = 1 | 2 | 3 | 4 | 5
@@ -32,39 +32,16 @@ export interface GoodsFormPayload {
   download: string
   download: string
 }
 }
 
 
-function toAbsoluteUrl(url: string): string {
-  const s = url.trim()
-  if (!s) return s
-  if (/^(https?:)?\/\//i.test(s) || s.startsWith('data:') || s.startsWith('blob:')) {
-    return s
-  }
-  const apiOrigin = (() => {
-    try {
-      if (!apiBase) return ''
-      return new URL(apiBase).origin
-    } catch {
-      return ''
-    }
-  })()
-  if (apiOrigin) {
-    const path = s.startsWith('/') ? s : `/${s}`
-    return new URL(path, apiOrigin).toString()
-  }
-  if (typeof window === 'undefined') return s
-  const path = s.startsWith('/') ? s : `/${s}`
-  return new URL(path, window.location.origin).toString()
-}
-
 function normalizeUploadCoverUrl(raw: unknown): string {
 function normalizeUploadCoverUrl(raw: unknown): string {
   if (typeof raw === 'string') {
   if (typeof raw === 'string') {
     const s = raw.trim()
     const s = raw.trim()
-    if (s) return toAbsoluteUrl(s)
+    if (s) return resolveMediaUrl(s)
   }
   }
   if (raw !== null && typeof raw === 'object') {
   if (raw !== null && typeof raw === 'object') {
     const o = raw as Record<string, unknown>
     const o = raw as Record<string, unknown>
     for (const k of ['frontUrl', 'fileUrl', 'url', 'fileURL', 'path'] as const) {
     for (const k of ['frontUrl', 'fileUrl', 'url', 'fileURL', 'path'] as const) {
       const v = o[k]
       const v = o[k]
-      if (typeof v === 'string' && v.trim()) return toAbsoluteUrl(v)
+      if (typeof v === 'string' && v.trim()) return resolveMediaUrl(v)
     }
     }
   }
   }
   throw new Error('上传返回无有效地址')
   throw new Error('上传返回无有效地址')

+ 3 - 2
src/api/modules/courses/goodsVideo.ts

@@ -1,17 +1,18 @@
 import type { AxiosHeaders } from 'axios'
 import type { AxiosHeaders } from 'axios'
+import { resolveMediaUrl } from '@/utils/resolveMediaUrl'
 import { axiosInstance, request } from '../../request'
 import { axiosInstance, request } from '../../request'
 import { type PageParam, unwrapPageList } from '../../types'
 import { type PageParam, unwrapPageList } from '../../types'
 
 
 function normalizeUploadVideoUrl(raw: unknown): string {
 function normalizeUploadVideoUrl(raw: unknown): string {
   if (typeof raw === 'string') {
   if (typeof raw === 'string') {
     const s = raw.trim()
     const s = raw.trim()
-    if (s) return s
+    if (s) return resolveMediaUrl(s)
   }
   }
   if (raw !== null && typeof raw === 'object') {
   if (raw !== null && typeof raw === 'object') {
     const o = raw as Record<string, unknown>
     const o = raw as Record<string, unknown>
     for (const k of ['fileUrl', 'url', 'fileURL', 'path'] as const) {
     for (const k of ['fileUrl', 'url', 'fileURL', 'path'] as const) {
       const v = o[k]
       const v = o[k]
-      if (typeof v === 'string' && v.trim()) return v.trim()
+      if (typeof v === 'string' && v.trim()) return resolveMediaUrl(v)
     }
     }
   }
   }
   throw new Error('上传返回无有效地址')
   throw new Error('上传返回无有效地址')

+ 8 - 2
src/config/env.ts

@@ -7,5 +7,11 @@ export const isDev = import.meta.env.DEV
 /** 是否生产构建(vite 内置,test 模式打包时为 false) */
 /** 是否生产构建(vite 内置,test 模式打包时为 false) */
 export const isProd = import.meta.env.PROD
 export const isProd = import.meta.env.PROD
 
 
-/** 接口根路径(来自对应 .env.* 中的 VITE_API_BASE) */
-export const apiBase = import.meta.env.VITE_API_BASE as string
+/** 线上站点根(接口与静态/上传文件同源:ad.jinclab.com) */
+export const PRODUCTION_SITE_URL = 'https://ad.jinclab.com'
+
+/** 接口根路径:development / test 读 VITE_API_BASE;production 构建固定为线上站点 */
+export const apiBase =
+  import.meta.env.MODE === 'production'
+    ? PRODUCTION_SITE_URL
+    : ((import.meta.env.VITE_API_BASE as string) || '')

+ 12 - 2
src/layouts/AdminLayout.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 <script setup lang="ts">
 import type { MenuOption } from 'naive-ui'
 import type { MenuOption } from 'naive-ui'
 import { NIcon, useMessage } from 'naive-ui'
 import { NIcon, useMessage } from 'naive-ui'
-import { BookOutline, LogoUsd, MenuOutline } from '@vicons/ionicons5'
+import { BookOutline, LogoUsd, MenuOutline, PeopleOutline } from '@vicons/ionicons5'
 import RouteTabBar from '@/components/RouteTabBar.vue'
 import RouteTabBar from '@/components/RouteTabBar.vue'
 import { setToken } from '@/api/request'
 import { setToken } from '@/api/request'
 import { useAppStore } from '@/stores/app'
 import { useAppStore } from '@/stores/app'
@@ -27,6 +27,7 @@ function renderIcon(icon: typeof BookOutline) {
 }
 }
 
 
 const SUBMENU_COURSES = 'submenu-courses'
 const SUBMENU_COURSES = 'submenu-courses'
+const SUBMENU_CUSTOMERS = 'submenu-customers'
 const SUBMENU_FINANCE = 'submenu-finance'
 const SUBMENU_FINANCE = 'submenu-finance'
 
 
 const menuOptions: MenuOption[] = [
 const menuOptions: MenuOption[] = [
@@ -40,6 +41,12 @@ const menuOptions: MenuOption[] = [
       { label: '教学内容', key: '/courses/goods' },
       { label: '教学内容', key: '/courses/goods' },
     ],
     ],
   },
   },
+  {
+    label: '客户管理',
+    key: SUBMENU_CUSTOMERS,
+    icon: renderIcon(PeopleOutline),
+    children: [{ label: '客户列表', key: '/customers/list' }],
+  },
   {
   {
     label: '财务管理',
     label: '财务管理',
     key: SUBMENU_FINANCE,
     key: SUBMENU_FINANCE,
@@ -47,7 +54,6 @@ const menuOptions: MenuOption[] = [
     children: [
     children: [
       { label: '取款申请', key: '/finance/withdraw-apply' },
       { label: '取款申请', key: '/finance/withdraw-apply' },
       { label: '订单列表', key: '/finance/order-list' },
       { label: '订单列表', key: '/finance/order-list' },
-      { label: '客户列表', key: '/finance/customer-list' },
     ],
     ],
   },
   },
 ]
 ]
@@ -61,6 +67,10 @@ watch(
       expandedKeys.value = [SUBMENU_COURSES]
       expandedKeys.value = [SUBMENU_COURSES]
       return
       return
     }
     }
+    if (p.startsWith('/customers')) {
+      expandedKeys.value = [SUBMENU_CUSTOMERS]
+      return
+    }
     if (p.startsWith('/finance')) {
     if (p.startsWith('/finance')) {
       expandedKeys.value = [SUBMENU_FINANCE]
       expandedKeys.value = [SUBMENU_FINANCE]
       return
       return

+ 7 - 3
src/router/index.ts

@@ -72,15 +72,19 @@ const router = createRouter({
           component: () => import('@/views/finance/OrderListView.vue'),
           component: () => import('@/views/finance/OrderListView.vue'),
         },
         },
         {
         {
-          path: 'finance/customer-list',
-          name: 'FinanceCustomerList',
+          path: 'customers/list',
+          name: 'CustomerList',
           meta: {
           meta: {
             title: '客户列表',
             title: '客户列表',
-            breadcrumb: ['财务管理', '客户列表'],
+            breadcrumb: ['客户管理', '客户列表'],
             requiresAuth: true,
             requiresAuth: true,
           },
           },
           component: () => import('@/views/finance/CustomerListView.vue'),
           component: () => import('@/views/finance/CustomerListView.vue'),
         },
         },
+        {
+          path: 'finance/customer-list',
+          redirect: '/customers/list',
+        },
         {
         {
           path: 'courses/finance/withdraw-apply',
           path: 'courses/finance/withdraw-apply',
           redirect: '/finance/withdraw-apply',
           redirect: '/finance/withdraw-apply',

+ 55 - 0
src/utils/resolveMediaUrl.ts

@@ -0,0 +1,55 @@
+import { PRODUCTION_SITE_URL, apiBase } from '@/config/env'
+
+/** 历史文件服务主机:接口仍可能返回该主机下的绝对 URL */
+const LEGACY_FILE_HOSTS = new Set(['103.158.191.66'])
+
+function productionMediaOrigin(): string {
+  return new URL(PRODUCTION_SITE_URL).origin
+}
+
+/** 非 production 用当前接口根;production 固定线上站点,避免与 window 或 env 混用 */
+function mediaBaseUrl(): string {
+  if (import.meta.env.MODE === 'production') return PRODUCTION_SITE_URL
+  if (apiBase) return apiBase
+  if (typeof window !== 'undefined') return window.location.origin
+  return ''
+}
+
+/**
+ * 将接口返回的图片/文件地址转为浏览器可访问的绝对 URL。
+ * production:相对路径与旧内网主机统一解析到 https://ad.jinclab.com
+ */
+export function resolveMediaUrl(url?: string | null): string {
+  const s = String(url ?? '').trim()
+  if (!s) return ''
+  if (s.startsWith('data:') || s.startsWith('blob:')) return s
+
+  if (/^(https?:)?\/\//i.test(s)) {
+    try {
+      const normalized = s.startsWith('//') ? `https:${s}` : s
+      const u = new URL(normalized)
+      if (import.meta.env.MODE === 'production') {
+        const origin = productionMediaOrigin()
+        if (LEGACY_FILE_HOSTS.has(u.hostname)) {
+          return `${origin}${u.pathname}${u.search}${u.hash}`
+        }
+        if (u.hostname === 'ad.jinclab.com' && u.protocol === 'http:') {
+          return `https://${u.host}${u.pathname}${u.search}${u.hash}`
+        }
+      }
+      return s
+    } catch {
+      return s
+    }
+  }
+
+  const path = s.startsWith('/') ? s : `/${s}`
+  const root = mediaBaseUrl()
+  try {
+    if (root) return new URL(path, root).toString()
+  } catch {
+    /* ignore */
+  }
+  if (typeof window === 'undefined') return path
+  return new URL(path, window.location.origin).toString()
+}

+ 3 - 19
src/views/courses/GoodsVideoManageModal.vue

@@ -19,7 +19,7 @@ import {
 } from "naive-ui";
 } from "naive-ui";
 import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
 import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
 import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
 import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
-import { apiBase } from "@/config/env";
+import { resolveMediaUrl } from "@/utils/resolveMediaUrl";
 import { useTableColumnsControl } from "@/composables";
 import { useTableColumnsControl } from "@/composables";
 import type { GoodsItem } from "@/api/modules/courses/goods";
 import type { GoodsItem } from "@/api/modules/courses/goods";
 import type { GoodsVideoItem } from "@/api/modules/courses/goodsVideo";
 import type { GoodsVideoItem } from "@/api/modules/courses/goodsVideo";
@@ -239,22 +239,6 @@ function normFile(v: unknown): 1 | 2 {
   return Number(v) === 2 ? 2 : 1;
   return Number(v) === 2 ? 2 : 1;
 }
 }
 
 
-function resolveCoverUrl(url?: string | null): string {
-  const s = String(url ?? "").trim();
-  if (!s) return "";
-  if (/^(https?:)?\/\//i.test(s) || s.startsWith("data:") || s.startsWith("blob:")) {
-    return s;
-  }
-  const path = s.startsWith("/") ? s : `/${s}`;
-  try {
-    if (apiBase) return new URL(path, apiBase).toString();
-  } catch {
-    // ignore invalid env and fallback to current origin
-  }
-  if (typeof window === "undefined") return path;
-  return new URL(path, window.location.origin).toString();
-}
-
 function openVideoEdit(row: GoodsVideoItem) {
 function openVideoEdit(row: GoodsVideoItem) {
   const intro = (row as GoodsVideoItem & { introduction?: string }).introduction;
   const intro = (row as GoodsVideoItem & { introduction?: string }).introduction;
   videoEditingId.value = row.id;
   videoEditingId.value = row.id;
@@ -392,7 +376,7 @@ const allVideoColumns = ref<DataTableColumns<GoodsVideoItem>>([
     width: 96,
     width: 96,
     fixed: "left",
     fixed: "left",
     render(row) {
     render(row) {
-      const src = resolveCoverUrl(row.frontUrl);
+      const src = resolveMediaUrl(row.frontUrl);
       if (!src) return "-";
       if (!src) return "-";
       return h(NImage, {
       return h(NImage, {
         src,
         src,
@@ -552,7 +536,7 @@ const { visibleKeys: videoVisibleKeys, displayColumns: videoDisplayColumns, colu
           </NSpace>
           </NSpace>
           <img
           <img
             v-if="videoForm.frontUrl"
             v-if="videoForm.frontUrl"
-            :src="videoForm.frontUrl"
+            :src="resolveMediaUrl(videoForm.frontUrl)"
             alt="封面预览"
             alt="封面预览"
             class="video-cover-preview-image"
             class="video-cover-preview-image"
           />
           />

+ 3 - 19
src/views/courses/GoodsView.vue

@@ -15,7 +15,7 @@ import {
 } from "naive-ui";
 } from "naive-ui";
 import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
 import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
 import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
 import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
-import { apiBase } from "@/config/env";
+import { resolveMediaUrl } from "@/utils/resolveMediaUrl";
 import {
 import {
   useConfirmRowDelete,
   useConfirmRowDelete,
   usePagedKeywordList,
   usePagedKeywordList,
@@ -277,22 +277,6 @@ function typeLabel(t: number) {
   return GOODS_TYPE_MAP[t] ?? `类型${t}`;
   return GOODS_TYPE_MAP[t] ?? `类型${t}`;
 }
 }
 
 
-function resolveCoverUrl(url?: string | null): string {
-  const s = String(url ?? "").trim();
-  if (!s) return "";
-  if (/^(https?:)?\/\//i.test(s) || s.startsWith("data:") || s.startsWith("blob:")) {
-    return s;
-  }
-  const path = s.startsWith("/") ? s : `/${s}`;
-  try {
-    if (apiBase) return new URL(path, apiBase).toString();
-  } catch {
-    // ignore invalid env and fallback to current origin
-  }
-  if (typeof window === "undefined") return path;
-  return new URL(path, window.location.origin).toString();
-}
-
 const allColumns = ref<DataTableColumns<GoodsItem>>([
 const allColumns = ref<DataTableColumns<GoodsItem>>([
   {
   {
     title: "封面",
     title: "封面",
@@ -300,7 +284,7 @@ const allColumns = ref<DataTableColumns<GoodsItem>>([
     width: 96,
     width: 96,
     fixed: "left",
     fixed: "left",
     render(row) {
     render(row) {
-      const src = resolveCoverUrl(row.frontUrl);
+      const src = resolveMediaUrl(row.frontUrl);
       if (!src) return "-";
       if (!src) return "-";
       return h(NImage, {
       return h(NImage, {
         src,
         src,
@@ -467,7 +451,7 @@ const { visibleKeys, displayColumns, columnOptions } =
             </NSpace>
             </NSpace>
             <img
             <img
               v-if="form.frontUrl"
               v-if="form.frontUrl"
-              :src="form.frontUrl"
+              :src="resolveMediaUrl(form.frontUrl)"
               alt="封面预览"
               alt="封面预览"
               class="cover-preview-image"
               class="cover-preview-image"
             />
             />

+ 2 - 1
src/views/finance/OrderListView.vue

@@ -20,6 +20,7 @@ import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
 import * as orderApi from "@/api/modules/finance/order";
 import * as orderApi from "@/api/modules/finance/order";
 import type { OrderItem, OrderSearchParams } from "@/api/modules/finance/order";
 import type { OrderItem, OrderSearchParams } from "@/api/modules/finance/order";
 import { useTableColumnsControl } from "@/composables";
 import { useTableColumnsControl } from "@/composables";
+import { resolveMediaUrl } from "@/utils/resolveMediaUrl";
 
 
 const message = useMessage();
 const message = useMessage();
 const dialog = useDialog();
 const dialog = useDialog();
@@ -382,7 +383,7 @@ onActivated(() => {
         >
         >
           <NImage
           <NImage
             class="order-course-item__cover"
             class="order-course-item__cover"
-            :src="item.frontUrl"
+            :src="resolveMediaUrl(item.frontUrl)"
             object-fit="cover"
             object-fit="cover"
             width="88"
             width="88"
             height="88"
             height="88"