| @@ -0,0 +1,5 @@ | |||||
| # Local development API. Copy this file to .env when a different address is needed. | |||||
| NUXT_PUBLIC_API_BASE=http://192.168.31.168:16888 | |||||
| # Public site origin used for canonical URLs when configured. | |||||
| NUXT_PUBLIC_SITE_URL=http://localhost:6888 | |||||
| @@ -96,17 +96,27 @@ const res = await api.post('/user/login', { username, password }) | |||||
| | --- | --- | | | --- | --- | | ||||
| | `0` | 成功,数据在 `data` / `list` 字段 | | | `0` | 成功,数据在 `data` / `list` 字段 | | ||||
| | `1000` | 业务异常,错误信息在 `msg` | | | `1000` | 业务异常,错误信息在 `msg` | | ||||
| | `1009` | 登录失效,客户端自动跳转 `/login` | | |||||
| | `1009` | 登录失效,客户端清除登录态并打开当前页面的登录弹窗 | | |||||
| ### 配置后端地址 | ### 配置后端地址 | ||||
| ```bash | ```bash | ||||
| # 环境变量(优先级最高) | # 环境变量(优先级最高) | ||||
| API_BASE=http://192.168.1.100:8360 pnpm run dev | |||||
| NUXT_PUBLIC_API_BASE=http://192.168.1.100:8360 pnpm run dev | |||||
| # 或直接修改 nuxt.config.js 中的 runtimeConfig.public.apiBase | |||||
| # 未设置时,开发环境默认使用 http://192.168.31.168:16888 | |||||
| # 也可以复制 .env.example 为 .env 后修改;不要直接修改 nuxt.config.js | |||||
| ``` | ``` | ||||
| 生产环境默认使用 `https://api.aionline.cc`。如需覆盖,必须使用公开的 HTTP(S) 地址: | |||||
| ```bash | |||||
| NUXT_PUBLIC_API_BASE=https://api.aionline.cc ./restart.sh | |||||
| ``` | |||||
| `restart.sh` 会在拉取和构建前校验该地址,拒绝 localhost、环回地址和局域网地址;同一变量会同时传入 Nuxt 构建、Nitro 运行时和 PM2,避免 SSR 与浏览器代理使用不同后端。 | |||||
| 发布脚本也会拒绝 `NODE_TLS_REJECT_UNAUTHORIZED=0`,生产机应保持 TLS 证书校验开启。 | |||||
| ### 服务端代理 | ### 服务端代理 | ||||
| 前端调 `/api/**` 会自动代理到 `ai_api`,路径示例: | 前端调 `/api/**` 会自动代理到 `ai_api`,路径示例: | ||||
| @@ -407,7 +407,11 @@ const strengthCopy = computed(() => '推荐 0.6 - 0.8,保留人脸特征') | |||||
| // ==================== Upload ==================== | // ==================== Upload ==================== | ||||
| function triggerUpload() { | function triggerUpload() { | ||||
| if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return } | |||||
| if (!user.isLogin.value) { | |||||
| showToast('请先登录') | |||||
| user.showLogin.value = true | |||||
| return | |||||
| } | |||||
| fileInput.value?.click() | fileInput.value?.click() | ||||
| } | } | ||||
| function onFileChange(e) { | function onFileChange(e) { | ||||
| @@ -518,6 +522,9 @@ onMounted(() => { | |||||
| loadDemo() | loadDemo() | ||||
| if (user.isLogin.value) loadBalance() | if (user.isLogin.value) loadBalance() | ||||
| }) | }) | ||||
| watch(() => user.isLogin.value, (loggedIn) => { | |||||
| if (loggedIn) loadBalance() | |||||
| }) | |||||
| onUnmounted(() => clearPoll()) | onUnmounted(() => clearPoll()) | ||||
| // ==================== 历史记录 / 详情 ==================== | // ==================== 历史记录 / 详情 ==================== | ||||
| @@ -661,7 +668,11 @@ async function startPoll() { | |||||
| } | } | ||||
| async function handleSubmit() { | async function handleSubmit() { | ||||
| if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return } | |||||
| if (!user.isLogin.value) { | |||||
| showToast('请先登录') | |||||
| user.showLogin.value = true | |||||
| return | |||||
| } | |||||
| if (uploading.value) { showToast('图片正在上传'); return } | if (uploading.value) { showToast('图片正在上传'); return } | ||||
| if (!form.imgs.length) { showToast('请上传参考图'); return } | if (!form.imgs.length) { showToast('请上传参考图'); return } | ||||
| if (submitting.value) return | if (submitting.value) return | ||||
| @@ -855,6 +855,9 @@ async function loadBalance() { | |||||
| user.refreshBalance() | user.refreshBalance() | ||||
| } | } | ||||
| onMounted(() => { loadBilling(); if (user.isLogin.value) loadBalance() }) | onMounted(() => { loadBilling(); if (user.isLogin.value) loadBalance() }) | ||||
| watch(() => user.isLogin.value, (loggedIn) => { | |||||
| if (loggedIn) loadBalance() | |||||
| }) | |||||
| const submitting = ref(false) | const submitting = ref(false) | ||||
| const questionTitle = ref('') | const questionTitle = ref('') | ||||
| @@ -880,7 +883,11 @@ const streamContent = ref('') | |||||
| const streamPreviewRef = ref(null) | const streamPreviewRef = ref(null) | ||||
| async function handleSubmit() { | async function handleSubmit() { | ||||
| if (!user.isLogin.value) { showToast('请先登录'); return router.push('/login') } | |||||
| if (!user.isLogin.value) { | |||||
| showToast('请先登录') | |||||
| user.showLogin.value = true | |||||
| return | |||||
| } | |||||
| if (!form.surname) { showToast('请输入姓氏'); return } | if (!form.surname) { showToast('请输入姓氏'); return } | ||||
| if (submitting.value) return | if (submitting.value) return | ||||
| syncBirthFields() | syncBirthFields() | ||||
| @@ -1,14 +1,14 @@ | |||||
| const appName = process.env.APP_NAME || 'pc_nuxt' | const appName = process.env.APP_NAME || 'pc_nuxt' | ||||
| const host = process.env.HOST || '0.0.0.0' | const host = process.env.HOST || '0.0.0.0' | ||||
| const port = process.env.PORT || '6888' | const port = process.env.PORT || '6888' | ||||
| const apiBase = process.env.API_BASE || 'https://api.aionline.cc' | |||||
| const apiBase = process.env.NUXT_PUBLIC_API_BASE || 'https://api.aionline.cc' | |||||
| const env = { | const env = { | ||||
| NODE_ENV: 'production', | NODE_ENV: 'production', | ||||
| HOST: host, | HOST: host, | ||||
| PORT: port, | PORT: port, | ||||
| NITRO_HOST: host, | NITRO_HOST: host, | ||||
| NITRO_PORT: port, | NITRO_PORT: port, | ||||
| API_BASE: apiBase | |||||
| NUXT_PUBLIC_API_BASE: apiBase | |||||
| } | } | ||||
| module.exports = { | module.exports = { | ||||
| @@ -1,5 +1,47 @@ | |||||
| import { fileURLToPath } from 'node:url' | import { fileURLToPath } from 'node:url' | ||||
| const isProduction = process.env.NODE_ENV === 'production' | |||||
| const defaultApiBase = isProduction | |||||
| ? 'https://api.aionline.cc' | |||||
| : 'http://192.168.31.168:16888' | |||||
| function resolveApiBase() { | |||||
| const value = String(process.env.NUXT_PUBLIC_API_BASE || defaultApiBase).trim().replace(/\/+$/, '') | |||||
| let url | |||||
| try { | |||||
| url = new URL(value) | |||||
| } catch { | |||||
| throw new Error(`[pc_nuxt] NUXT_PUBLIC_API_BASE 不是有效 URL:${value || '(empty)'}`) | |||||
| } | |||||
| if (!['http:', 'https:'].includes(url.protocol)) { | |||||
| throw new Error(`[pc_nuxt] NUXT_PUBLIC_API_BASE 仅支持 http/https:${value}`) | |||||
| } | |||||
| const hostname = url.hostname.toLowerCase() | |||||
| const isPrivateHost = hostname === 'localhost' | |||||
| || hostname === '0.0.0.0' | |||||
| || /^127\./.test(hostname) | |||||
| || hostname === '[::1]' | |||||
| || hostname.endsWith('.local') | |||||
| || /^10\./.test(hostname) | |||||
| || /^192\.168\./.test(hostname) | |||||
| || /^169\.254\./.test(hostname) | |||||
| || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) | |||||
| if (isProduction && isPrivateHost) { | |||||
| throw new Error(`[pc_nuxt] 生产环境禁止使用本地/局域网 API:${value},请设置 NUXT_PUBLIC_API_BASE`) | |||||
| } | |||||
| return value | |||||
| } | |||||
| if (process.env.API_BASE && !process.env.NUXT_PUBLIC_API_BASE) { | |||||
| console.warn('[pc_nuxt] API_BASE 已废弃,请改用 NUXT_PUBLIC_API_BASE') | |||||
| } | |||||
| const apiBase = resolveApiBase() | |||||
| // https://nuxt.com/docs/api/configuration/nuxt-config | // https://nuxt.com/docs/api/configuration/nuxt-config | ||||
| export default defineNuxtConfig({ | export default defineNuxtConfig({ | ||||
| compatibilityDate: '2025-07-15', | compatibilityDate: '2025-07-15', | ||||
| @@ -32,7 +74,7 @@ export default defineNuxtConfig({ | |||||
| runtimeConfig: { | runtimeConfig: { | ||||
| public: { | public: { | ||||
| // ThinkJS ai_api 地址,按环境覆盖 | // ThinkJS ai_api 地址,按环境覆盖 | ||||
| apiBase: process.env.API_BASE || 'http://192.168.31.168:16888', | |||||
| apiBase, | |||||
| // 邀请链接正式域名;未配置时自动使用当前访问域名。 | // 邀请链接正式域名;未配置时自动使用当前访问域名。 | ||||
| siteUrl: process.env.NUXT_PUBLIC_SITE_URL || '', | siteUrl: process.env.NUXT_PUBLIC_SITE_URL || '', | ||||
| }, | }, | ||||
| @@ -4,8 +4,11 @@ set -Eeuo pipefail | |||||
| APP_NAME="${APP_NAME:-pc_nuxt}" | APP_NAME="${APP_NAME:-pc_nuxt}" | ||||
| BRANCH="${BRANCH:-}" | BRANCH="${BRANCH:-}" | ||||
| NUXT_PUBLIC_API_BASE="${NUXT_PUBLIC_API_BASE:-https://api.aionline.cc}" | |||||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||||
| export NUXT_PUBLIC_API_BASE | |||||
| cd "$SCRIPT_DIR" | cd "$SCRIPT_DIR" | ||||
| log() { | log() { | ||||
| @@ -19,6 +22,40 @@ require_command() { | |||||
| fi | fi | ||||
| } | } | ||||
| validate_api_base() { | |||||
| node - "$NUXT_PUBLIC_API_BASE" <<'NODE' | |||||
| const value = process.argv[2] | |||||
| let url | |||||
| try { | |||||
| url = new URL(value) | |||||
| } catch { | |||||
| console.error(`[pc_nuxt] Invalid NUXT_PUBLIC_API_BASE: ${value || '(empty)'}`) | |||||
| process.exit(1) | |||||
| } | |||||
| const hostname = url.hostname.toLowerCase() | |||||
| const isPrivate = hostname === 'localhost' | |||||
| || hostname === '0.0.0.0' | |||||
| || /^127\./.test(hostname) | |||||
| || hostname === '[::1]' | |||||
| || hostname.endsWith('.local') | |||||
| || /^10\./.test(hostname) | |||||
| || /^192\.168\./.test(hostname) | |||||
| || /^169\.254\./.test(hostname) | |||||
| || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) | |||||
| if (!['http:', 'https:'].includes(url.protocol) || isPrivate) { | |||||
| console.error(`[pc_nuxt] Production API must be a public http(s) URL: ${value}`) | |||||
| process.exit(1) | |||||
| } | |||||
| NODE | |||||
| } | |||||
| validate_tls_security() { | |||||
| if [ "${NODE_TLS_REJECT_UNAUTHORIZED:-}" = "0" ]; then | |||||
| log "Refusing deployment: NODE_TLS_REJECT_UNAUTHORIZED=0 disables TLS certificate verification." | |||||
| exit 1 | |||||
| fi | |||||
| } | |||||
| ensure_clean_worktree() { | ensure_clean_worktree() { | ||||
| if [ -n "$(git status --porcelain)" ]; then | if [ -n "$(git status --porcelain)" ]; then | ||||
| log "Working tree is not clean. Commit/stash/clean changes before deploy." | log "Working tree is not clean. Commit/stash/clean changes before deploy." | ||||
| @@ -71,12 +108,15 @@ main() { | |||||
| require_command node | require_command node | ||||
| require_command pnpm | require_command pnpm | ||||
| require_command pm2 | require_command pm2 | ||||
| validate_tls_security | |||||
| validate_api_base | |||||
| resolve_branch | resolve_branch | ||||
| log "Deploy start: $APP_NAME" | log "Deploy start: $APP_NAME" | ||||
| log "Node: $(node -v 2>/dev/null || echo 'unknown')" | log "Node: $(node -v 2>/dev/null || echo 'unknown')" | ||||
| log "pnpm: $(pnpm -v)" | log "pnpm: $(pnpm -v)" | ||||
| log "pm2: $(pm2 -v)" | log "pm2: $(pm2 -v)" | ||||
| log "API: $NUXT_PUBLIC_API_BASE" | |||||
| pull_latest_code | pull_latest_code | ||||
| install_dependencies | install_dependencies | ||||
| @@ -8,12 +8,19 @@ | |||||
| * 1. 避免浏览器跨域 | * 1. 避免浏览器跨域 | ||||
| * 2. 服务端转发自动携带 cookie,保持登录态 | * 2. 服务端转发自动携带 cookie,保持登录态 | ||||
| * | * | ||||
| * 环境变量 API_BASE 可覆盖目标地址(生产环境指向 https://api.aionline.cc) | |||||
| * 环境变量 NUXT_PUBLIC_API_BASE 可覆盖目标地址(生产环境指向 https://api.aionline.cc) | |||||
| */ | */ | ||||
| import { proxyRequest, sendWebResponse } from 'h3' | import { proxyRequest, sendWebResponse } from 'h3' | ||||
| export default defineEventHandler(async (event) => { | export default defineEventHandler(async (event) => { | ||||
| const apiBase = process.env.API_BASE || 'http://192.168.31.168:16888' | |||||
| const config = useRuntimeConfig(event) | |||||
| const apiBase = String(config.public.apiBase || '').replace(/\/+$/, '') | |||||
| if (!apiBase) { | |||||
| throw createError({ | |||||
| statusCode: 500, | |||||
| statusMessage: 'NUXT_PUBLIC_API_BASE is not configured', | |||||
| }) | |||||
| } | |||||
| // 去掉 /api 前缀,得到真实后端路径 | // 去掉 /api 前缀,得到真实后端路径 | ||||
| let path = event.path.replace(/^\/api/, '') || '/' | let path = event.path.replace(/^\/api/, '') || '/' | ||||