| @@ -33,6 +33,7 @@ | |||||
| <div class="px-8"> | <div class="px-8"> | ||||
| <div class="flex border-b border-[var(--c-e2e8f0)]"> | <div class="flex border-b border-[var(--c-e2e8f0)]"> | ||||
| <button | <button | ||||
| v-if="!isMobileClient" | |||||
| :class="[ | :class="[ | ||||
| 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer', | 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer', | ||||
| activeTab === 'qr' | activeTab === 'qr' | ||||
| @@ -58,7 +59,7 @@ | |||||
| </div> | </div> | ||||
| <!-- 微信扫码登录 --> | <!-- 微信扫码登录 --> | ||||
| <div v-if="activeTab === 'qr'" class="px-8 py-6"> | |||||
| <div v-if="!isMobileClient && activeTab === 'qr'" class="px-8 py-6"> | |||||
| <div class="flex flex-col items-center"> | <div class="flex flex-col items-center"> | ||||
| <!-- 二维码区域 --> | <!-- 二维码区域 --> | ||||
| <div | <div | ||||
| @@ -210,10 +211,12 @@ const emit = defineEmits(['close', 'login-success']) | |||||
| const { post } = useApi() | const { post } = useApi() | ||||
| const { setLogin } = useUser() | const { setLogin } = useUser() | ||||
| const { inviteCode, withInvite, clearInvite } = useInvite() | const { inviteCode, withInvite, clearInvite } = useInvite() | ||||
| const { isMobileClient } = useClientDevice() | |||||
| const { reportCv } = usePvcv() | |||||
| const toast = useToast() | const toast = useToast() | ||||
| // Tab | // Tab | ||||
| const activeTab = ref('qr') | |||||
| const activeTab = ref(isMobileClient.value ? 'phone' : 'qr') | |||||
| // ===== 微信扫码 ===== | // ===== 微信扫码 ===== | ||||
| const qrLoading = ref(true) | const qrLoading = ref(true) | ||||
| @@ -226,6 +229,7 @@ let qrTimer = null | |||||
| // 获取二维码 | // 获取二维码 | ||||
| async function refreshQR() { | async function refreshQR() { | ||||
| if (isMobileClient.value) return | |||||
| clearInterval(qrTimer) | clearInterval(qrTimer) | ||||
| qrLoading.value = true | qrLoading.value = true | ||||
| qrExpired.value = false | qrExpired.value = false | ||||
| @@ -365,6 +369,7 @@ function handleLoginSuccess(data) { | |||||
| const invited = Boolean(inviteCode.value) | const invited = Boolean(inviteCode.value) | ||||
| setLogin(data) | setLogin(data) | ||||
| clearInvite() | clearInvite() | ||||
| reportCv('login_success', { page_key: 'login_dialog' }) | |||||
| toast.add({ | toast.add({ | ||||
| title: invited && data.is_register ? '登录并领取成功' : '登录成功', | title: invited && data.is_register ? '登录并领取成功' : '登录成功', | ||||
| description: invited && data.is_register ? '邀请奖励点数已自动到账。' : '', | description: invited && data.is_register ? '邀请奖励点数已自动到账。' : '', | ||||
| @@ -375,7 +380,17 @@ function handleLoginSuccess(data) { | |||||
| // 初始化 | // 初始化 | ||||
| onMounted(() => { | onMounted(() => { | ||||
| refreshQR() | |||||
| reportCv('login_dialog_show', { page_key: 'login_dialog' }) | |||||
| if (!isMobileClient.value) refreshQR() | |||||
| }) | |||||
| watch(isMobileClient, (mobile) => { | |||||
| if (mobile) { | |||||
| clearInterval(qrTimer) | |||||
| activeTab.value = 'phone' | |||||
| } else if (activeTab.value === 'qr' && !qrData.value.url) { | |||||
| refreshQR() | |||||
| } | |||||
| }) | }) | ||||
| onUnmounted(() => { | onUnmounted(() => { | ||||
| @@ -6,7 +6,7 @@ | |||||
| * | * | ||||
| * 自动注入(对齐 V2): | * 自动注入(对齐 V2): | ||||
| * Header: { token, timestr, nonce } | * Header: { token, timestr, nonce } | ||||
| * Body: { token, uid, mac, base_timestamp, client:1, source, client_ios, version, version_code } | |||||
| * Body: { token, uid, mac, base_timestamp, client, source, client_ios, version, version_code } | |||||
| * | * | ||||
| * 后端响应规范: | * 后端响应规范: | ||||
| * { code: 0, data: {...}, list: [...], msg: '...' } // 成功 | * { code: 0, data: {...}, list: [...], msg: '...' } // 成功 | ||||
| @@ -44,6 +44,7 @@ export function useApi() { | |||||
| const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' }) | const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' }) | ||||
| const openIdCookie = useCookie('open_id', { path: '/' }) | const openIdCookie = useCookie('open_id', { path: '/' }) | ||||
| const sourceCookie = useCookie('source', { path: '/' }) | const sourceCookie = useCookie('source', { path: '/' }) | ||||
| const { getClientId, getClientIos } = useClientDevice() | |||||
| /** | /** | ||||
| * 登录态从 useState 共享状态读取(与 useUser 同源)。 | * 登录态从 useState 共享状态读取(与 useUser 同源)。 | ||||
| @@ -116,9 +117,9 @@ export function useApi() { | |||||
| uid: openIdState.value || openIdCookie.value || '', | uid: openIdState.value || openIdCookie.value || '', | ||||
| mac: macCookie.value || SSR_MAC, | mac: macCookie.value || SSR_MAC, | ||||
| base_timestamp: timestamp, | base_timestamp: timestamp, | ||||
| client: 1, | |||||
| client: getClientId(), | |||||
| source: sourceCookie.value || '', | source: sourceCookie.value || '', | ||||
| client_ios: 0, | |||||
| client_ios: getClientIos(), | |||||
| version: '1.0.0', | version: '1.0.0', | ||||
| version_code: 1, | version_code: 1, | ||||
| } | } | ||||
| @@ -180,9 +181,9 @@ export function useApi() { | |||||
| uid: openIdState.value || openIdCookie.value || '', | uid: openIdState.value || openIdCookie.value || '', | ||||
| mac: macCookie.value || SSR_MAC, | mac: macCookie.value || SSR_MAC, | ||||
| base_timestamp: Math.floor(Date.now() / 1000), | base_timestamp: Math.floor(Date.now() / 1000), | ||||
| client: 1, | |||||
| client: getClientId(), | |||||
| source: sourceCookie.value || '', | source: sourceCookie.value || '', | ||||
| client_ios: 0, | |||||
| client_ios: getClientIos(), | |||||
| version: '1.0.0', | version: '1.0.0', | ||||
| version_code: 1, | version_code: 1, | ||||
| ...body, | ...body, | ||||
| @@ -0,0 +1,57 @@ | |||||
| const MOBILE_UA_RE = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile/i | |||||
| const IOS_UA_RE = /iphone|ipad|ipod|ios/i | |||||
| const WECHAT_UA_RE = /micromessenger/i | |||||
| export function resolveClientDevice(input = {}) { | |||||
| const host = String(input.host || '').split(':')[0].toLowerCase() | |||||
| const userAgent = String(input.userAgent || '') | |||||
| const isMobileHost = host === 'm.aionline.cc' || host.startsWith('m.') | |||||
| const isMobile = isMobileHost || MOBILE_UA_RE.test(userAgent) | |||||
| return { | |||||
| client: isMobile ? 2 : 1, | |||||
| isMobile, | |||||
| isIos: IOS_UA_RE.test(userAgent), | |||||
| isWechat: WECHAT_UA_RE.test(userAgent), | |||||
| host, | |||||
| userAgent, | |||||
| } | |||||
| } | |||||
| export function useClientDevice() { | |||||
| function getHost() { | |||||
| if (import.meta.client) return window.location.hostname || '' | |||||
| const headers = useRequestHeaders(['host']) | |||||
| return headers.host || '' | |||||
| } | |||||
| function getUserAgent() { | |||||
| if (import.meta.client) return navigator.userAgent || '' | |||||
| const headers = useRequestHeaders(['user-agent']) | |||||
| return headers['user-agent'] || '' | |||||
| } | |||||
| function getMeta() { | |||||
| return resolveClientDevice({ | |||||
| host: getHost(), | |||||
| userAgent: getUserAgent(), | |||||
| }) | |||||
| } | |||||
| const device = computed(() => getMeta()) | |||||
| const clientId = computed(() => device.value.client) | |||||
| const isMobileClient = computed(() => device.value.isMobile) | |||||
| const isIosClient = computed(() => device.value.isIos) | |||||
| const isWechatClient = computed(() => device.value.isWechat) | |||||
| return { | |||||
| device, | |||||
| clientId, | |||||
| isMobileClient, | |||||
| isIosClient, | |||||
| isWechatClient, | |||||
| getClientId: () => getMeta().client, | |||||
| getClientIos: () => (getMeta().isIos ? 1 : 0), | |||||
| getClientMeta: getMeta, | |||||
| } | |||||
| } | |||||
| @@ -5,6 +5,7 @@ export function useIdPhoto() { | |||||
| const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' }) | const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' }) | ||||
| const openIdCookie = useCookie('open_id', { path: '/' }) | const openIdCookie = useCookie('open_id', { path: '/' }) | ||||
| const sourceCookie = useCookie('source', { path: '/' }) | const sourceCookie = useCookie('source', { path: '/' }) | ||||
| const { getClientId, getClientIos } = useClientDevice() | |||||
| const tokenState = useState('auth_token', () => tokenCookie.value || '') | const tokenState = useState('auth_token', () => tokenCookie.value || '') | ||||
| const openIdState = useState('auth_open_id', () => openIdCookie.value || '') | const openIdState = useState('auth_open_id', () => openIdCookie.value || '') | ||||
| @@ -41,9 +42,9 @@ export function useIdPhoto() { | |||||
| formData.append('uid', openIdState.value || openIdCookie.value || '') | formData.append('uid', openIdState.value || openIdCookie.value || '') | ||||
| formData.append('mac', macCookie.value || 'pc-id-photo') | formData.append('mac', macCookie.value || 'pc-id-photo') | ||||
| formData.append('base_timestamp', String(Math.floor(Date.now() / 1000))) | formData.append('base_timestamp', String(Math.floor(Date.now() / 1000))) | ||||
| formData.append('client', '1') | |||||
| formData.append('client', String(getClientId())) | |||||
| formData.append('source', sourceCookie.value || '') | formData.append('source', sourceCookie.value || '') | ||||
| formData.append('client_ios', '0') | |||||
| formData.append('client_ios', String(getClientIos())) | |||||
| formData.append('version', '1.0.0') | formData.append('version', '1.0.0') | ||||
| formData.append('version_code', '1') | formData.append('version_code', '1') | ||||
| formData.append('request_id', settings.request_id || createUuid()) | formData.append('request_id', settings.request_id || createUuid()) | ||||
| @@ -11,6 +11,7 @@ export function useOldPhoto() { | |||||
| const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' }) | const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' }) | ||||
| const openIdCookie = useCookie('open_id', { path: '/' }) | const openIdCookie = useCookie('open_id', { path: '/' }) | ||||
| const sourceCookie = useCookie('source', { path: '/' }) | const sourceCookie = useCookie('source', { path: '/' }) | ||||
| const { getClientId, getClientIos } = useClientDevice() | |||||
| const tokenState = useState('auth_token', () => tokenCookie.value || '') | const tokenState = useState('auth_token', () => tokenCookie.value || '') | ||||
| const openIdState = useState('auth_open_id', () => openIdCookie.value || '') | const openIdState = useState('auth_open_id', () => openIdCookie.value || '') | ||||
| @@ -49,9 +50,9 @@ export function useOldPhoto() { | |||||
| formData.append('uid', openIdState.value || openIdCookie.value || '') | formData.append('uid', openIdState.value || openIdCookie.value || '') | ||||
| formData.append('mac', macCookie.value || 'pc-old-photo') | formData.append('mac', macCookie.value || 'pc-old-photo') | ||||
| formData.append('base_timestamp', String(Math.floor(Date.now() / 1000))) | formData.append('base_timestamp', String(Math.floor(Date.now() / 1000))) | ||||
| formData.append('client', '1') | |||||
| formData.append('client', String(getClientId())) | |||||
| formData.append('source', sourceCookie.value || '') | formData.append('source', sourceCookie.value || '') | ||||
| formData.append('client_ios', '0') | |||||
| formData.append('client_ios', String(getClientIos())) | |||||
| formData.append('version', '1.0.0') | formData.append('version', '1.0.0') | ||||
| formData.append('version_code', '1') | formData.append('version_code', '1') | ||||
| formData.append('request_id', settings.request_id || createUuid()) | formData.append('request_id', settings.request_id || createUuid()) | ||||
| @@ -12,6 +12,7 @@ export function usePortrait() { | |||||
| const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' }) | const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' }) | ||||
| const openIdCookie = useCookie('open_id', { path: '/' }) | const openIdCookie = useCookie('open_id', { path: '/' }) | ||||
| const sourceCookie = useCookie('source', { path: '/' }) | const sourceCookie = useCookie('source', { path: '/' }) | ||||
| const { getClientId, getClientIos } = useClientDevice() | |||||
| const tokenState = useState('auth_token', () => tokenCookie.value || '') | const tokenState = useState('auth_token', () => tokenCookie.value || '') | ||||
| const openIdState = useState('auth_open_id', () => openIdCookie.value || '') | const openIdState = useState('auth_open_id', () => openIdCookie.value || '') | ||||
| @@ -52,9 +53,9 @@ export function usePortrait() { | |||||
| formData.append('uid', openIdState.value || openIdCookie.value || '') | formData.append('uid', openIdState.value || openIdCookie.value || '') | ||||
| formData.append('mac', macCookie.value || 'pc-portrait') | formData.append('mac', macCookie.value || 'pc-portrait') | ||||
| formData.append('base_timestamp', String(Math.floor(Date.now() / 1000))) | formData.append('base_timestamp', String(Math.floor(Date.now() / 1000))) | ||||
| formData.append('client', '1') | |||||
| formData.append('client', String(getClientId())) | |||||
| formData.append('source', sourceCookie.value || '') | formData.append('source', sourceCookie.value || '') | ||||
| formData.append('client_ios', '0') | |||||
| formData.append('client_ios', String(getClientIos())) | |||||
| formData.append('version', '1.0.0') | formData.append('version', '1.0.0') | ||||
| formData.append('version_code', '1') | formData.append('version_code', '1') | ||||
| formData.append('request_id', settings.request_id || createUuid()) | formData.append('request_id', settings.request_id || createUuid()) | ||||
| @@ -0,0 +1,44 @@ | |||||
| export function usePvcv() { | |||||
| const { post } = useApi() | |||||
| const { userInfo } = useUser() | |||||
| const route = useRoute() | |||||
| const sourceCookie = useCookie('source', { path: '/' }) | |||||
| function getPagePayload(toRoute = route, extra = {}) { | |||||
| const path = toRoute.path || window.location.pathname || '/' | |||||
| const fullPath = toRoute.fullPath || path | |||||
| const url = import.meta.client ? `${window.location.origin}${fullPath}` : fullPath | |||||
| return { | |||||
| source: sourceCookie.value || '', | |||||
| referrer: extra.referrer || (import.meta.client ? document.referrer : ''), | |||||
| url, | |||||
| path, | |||||
| page_key: extra.page_key || String(toRoute.name || path).replace(/^\/+/, ''), | |||||
| user_id: userInfo.value?.user_id || 0, | |||||
| user_agent: import.meta.client ? navigator.userAgent : '', | |||||
| ...extra, | |||||
| } | |||||
| } | |||||
| async function reportPv(toRoute = route, extra = {}) { | |||||
| if (!import.meta.client) return | |||||
| try { | |||||
| await post('/common/pvcv', getPagePayload(toRoute, extra)) | |||||
| } catch { | |||||
| // 统计不能影响主流程 | |||||
| } | |||||
| } | |||||
| async function reportCv(key, extra = {}) { | |||||
| if (!key) return | |||||
| await reportPv(route, { | |||||
| ...extra, | |||||
| key, | |||||
| }) | |||||
| } | |||||
| return { | |||||
| reportPv, | |||||
| reportCv, | |||||
| } | |||||
| } | |||||
| @@ -3,7 +3,7 @@ | |||||
| * | * | ||||
| * - POST multipart/form-data 到 /api/common/uploadpic(走服务端代理) | * - POST multipart/form-data 到 /api/common/uploadpic(走服务端代理) | ||||
| * - Header: { token, timestr, nonce } | * - Header: { token, timestr, nonce } | ||||
| * - FormData: { file, token, mac, client:1, source, version, version_code, upload_type:1 } | |||||
| * - FormData: { file, token, mac, client, source, version, version_code, upload_type:1 } | |||||
| */ | */ | ||||
| import md5 from '~/utils/md5' | import md5 from '~/utils/md5' | ||||
| @@ -12,6 +12,7 @@ export function useUpload() { | |||||
| const tokenCookie = useCookie('token', { path: '/' }) | const tokenCookie = useCookie('token', { path: '/' }) | ||||
| const openIdCookie = useCookie('open_id', { path: '/' }) | const openIdCookie = useCookie('open_id', { path: '/' }) | ||||
| const sourceCookie = useCookie('source', { path: '/' }) | const sourceCookie = useCookie('source', { path: '/' }) | ||||
| const { getClientId, getClientIos } = useClientDevice() | |||||
| function getNonce() { | function getNonce() { | ||||
| const timeStr = Date.now().toString() | const timeStr = Date.now().toString() | ||||
| @@ -40,9 +41,9 @@ export function useUpload() { | |||||
| formData.append('uid', openIdCookie.value || '') | formData.append('uid', openIdCookie.value || '') | ||||
| formData.append('mac', macCookie.value || '') | formData.append('mac', macCookie.value || '') | ||||
| formData.append('base_timestamp', timestamp) | formData.append('base_timestamp', timestamp) | ||||
| formData.append('client', '1') | |||||
| formData.append('client', String(getClientId())) | |||||
| formData.append('source', sourceCookie.value || '') | formData.append('source', sourceCookie.value || '') | ||||
| formData.append('client_ios', '0') | |||||
| formData.append('client_ios', String(getClientIos())) | |||||
| formData.append('version', '1.0.0') | formData.append('version', '1.0.0') | ||||
| formData.append('version_code', '1') | formData.append('version_code', '1') | ||||
| formData.append('upload_type', '1') | formData.append('upload_type', '1') | ||||
| @@ -162,8 +162,8 @@ | |||||
| <!-- 4. 比例与张数 --> | <!-- 4. 比例与张数 --> | ||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6"> | <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6"> | ||||
| <div class="flex items-start justify-between gap-4"> | <div class="flex items-start justify-between gap-4"> | ||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">图片比例与张数</h3> | |||||
| <span class="mt-1 text-xs font-extrabold text-[var(--c-0b8cff)]">{{ unitPoints }}点/张 · 共{{ totalPoints }}点</span> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">{{ quantityEnabled ? '图片比例与张数' : '图片比例' }}</h3> | |||||
| <span class="mt-1 text-xs font-extrabold text-[var(--c-0b8cff)]">{{ quantityPriceText }}</span> | |||||
| </div> | </div> | ||||
| <div class="mt-4 flex flex-wrap gap-2.5"> | <div class="mt-4 flex flex-wrap gap-2.5"> | ||||
| @@ -182,7 +182,9 @@ | |||||
| </button> | </button> | ||||
| </div> | </div> | ||||
| <div class="mt-5 flex items-center gap-4"> | |||||
| <div v-if="configLoading" class="mt-5 h-14 animate-pulse rounded-2xl bg-[var(--c-f1f5fa)]" /> | |||||
| <div v-else-if="quantityEnabled" class="mt-5 flex items-center gap-4"> | |||||
| <span class="text-sm font-bold text-[var(--c-526174)]">生成张数</span> | <span class="text-sm font-bold text-[var(--c-526174)]">生成张数</span> | ||||
| <div class="flex items-center gap-3"> | <div class="flex items-center gap-3"> | ||||
| <button | <button | ||||
| @@ -207,6 +209,14 @@ | |||||
| </div> | </div> | ||||
| <span class="text-xs text-[var(--c-8a97a8)]">一次最多 {{ maxCount }} 张,按张扣点,失败的不扣</span> | <span class="text-xs text-[var(--c-8a97a8)]">一次最多 {{ maxCount }} 张,按张扣点,失败的不扣</span> | ||||
| </div> | </div> | ||||
| <div v-else class="mt-5 rounded-2xl bg-[var(--c-f7faff)] px-4 py-3"> | |||||
| <div class="flex items-center justify-between gap-3"> | |||||
| <span class="text-sm font-bold text-[var(--c-526174)]">生成张数</span> | |||||
| <span class="rounded-full bg-[var(--c-eef7ff)] px-3 py-1 text-xs font-extrabold text-[var(--c-0b8cff)]">固定 1 张</span> | |||||
| </div> | |||||
| <p class="mt-1 text-xs leading-relaxed text-[var(--c-8a97a8)]">后台已关闭多张生成,本次只生成 1 张。</p> | |||||
| </div> | |||||
| </div> | </div> | ||||
| <!-- 5. 补充描述 --> | <!-- 5. 补充描述 --> | ||||
| @@ -288,7 +298,7 @@ | |||||
| <span class="animate-pulse-dot3 size-2.5 rounded-full bg-[var(--c-1f8cff)]" /> | <span class="animate-pulse-dot3 size-2.5 rounded-full bg-[var(--c-1f8cff)]" /> | ||||
| </div> | </div> | ||||
| <div class="text-sm font-bold text-[var(--c-172033)]"> | <div class="text-sm font-bold text-[var(--c-172033)]"> | ||||
| {{ job.success_count || 0 }} / {{ job.count || quantity }} 张 · 已等待 {{ waitedSeconds }}s | |||||
| {{ job.success_count || 0 }} / {{ job.count || effectiveQuantity }} 张 · 已等待 {{ waitedSeconds }}s | |||||
| </div> | </div> | ||||
| <div v-if="pollCountdown > 0" class="mt-2 text-xs text-[var(--c-8a95a6)]">下次刷新 {{ pollCountdown }}s</div> | <div v-if="pollCountdown > 0" class="mt-2 text-xs text-[var(--c-8a95a6)]">下次刷新 {{ pollCountdown }}s</div> | ||||
| </div> | </div> | ||||
| @@ -484,6 +494,7 @@ const activeRatio = ref('') | |||||
| const options = reactive({}) | const options = reactive({}) | ||||
| const quantity = ref(1) | const quantity = ref(1) | ||||
| const maxCount = ref(4) | const maxCount = ref(4) | ||||
| const quantityEnabled = ref(true) | |||||
| const unitPoints = ref(0) | const unitPoints = ref(0) | ||||
| const configLoading = ref(true) | const configLoading = ref(true) | ||||
| const submitting = ref(false) | const submitting = ref(false) | ||||
| @@ -508,7 +519,12 @@ const currentStyle = computed(() => styles.value.find(item => item.key === activ | |||||
| const currentGroups = computed(() => currentStyle.value.groups || []) | const currentGroups = computed(() => currentStyle.value.groups || []) | ||||
| const resultUrls = computed(() => job.value.result_urls || []) | const resultUrls = computed(() => job.value.result_urls || []) | ||||
| const activeResultUrl = computed(() => resultUrls.value[activeIndex.value] || resultUrls.value[0] || '') | const activeResultUrl = computed(() => resultUrls.value[activeIndex.value] || resultUrls.value[0] || '') | ||||
| const totalPoints = computed(() => unitPoints.value * quantity.value) | |||||
| const effectiveQuantity = computed(() => (quantityEnabled.value ? quantity.value : 1)) | |||||
| const totalPoints = computed(() => unitPoints.value * effectiveQuantity.value) | |||||
| const quantityPriceText = computed(() => { | |||||
| if (!unitPoints.value) return quantityEnabled.value ? `共${totalPoints.value}点` : '固定1张' | |||||
| return quantityEnabled.value ? `${unitPoints.value}点/张 · 共${totalPoints.value}点` : `${unitPoints.value}点/张 · 固定1张` | |||||
| }) | |||||
| const currentPoints = computed(() => Number(balance.points || 0)) | const currentPoints = computed(() => Number(balance.points || 0)) | ||||
| const isBalanceInsufficient = computed( | const isBalanceInsufficient = computed( | ||||
| () => user.isLogin.value && totalPoints.value > 0 && currentPoints.value < totalPoints.value, | () => user.isLogin.value && totalPoints.value > 0 && currentPoints.value < totalPoints.value, | ||||
| @@ -530,20 +546,51 @@ function showToast(message, color = 'error') { | |||||
| toast.add({ title: message, color }) | toast.add({ title: message, color }) | ||||
| } | } | ||||
| function pickFirstDefined(values) { | |||||
| return values.find(value => value !== undefined && value !== null && value !== '') | |||||
| } | |||||
| function normalizeQuantityEnabled(data = {}) { | |||||
| const raw = pickFirstDefined([ | |||||
| data.quantity_enabled, | |||||
| data.quantity_supported, | |||||
| data.product?.quantity_enabled, | |||||
| data.billing?.product?.quantity_enabled, | |||||
| data.billing?.quote?.product?.quantity_enabled, | |||||
| ]) | |||||
| if (raw === undefined) return Number(data.max_count || 1) > 1 | |||||
| if (typeof raw === 'boolean') return raw | |||||
| return !['0', 'false', 'no', 'off', 'unsupported', 'not_support', '不支持'].includes(String(raw).toLowerCase()) | |||||
| } | |||||
| function applyQuantityConfig(data = {}) { | |||||
| const enabled = normalizeQuantityEnabled(data) | |||||
| const rawMax = pickFirstDefined([ | |||||
| data.max_count, | |||||
| data.product?.max_quantity, | |||||
| data.billing?.product?.max_quantity, | |||||
| data.billing?.quote?.product?.max_quantity, | |||||
| ]) | |||||
| const nextMax = enabled ? Math.max(1, Number(rawMax || maxCount.value || 1)) : 1 | |||||
| quantityEnabled.value = enabled | |||||
| maxCount.value = nextMax | |||||
| quantity.value = enabled ? Math.min(Math.max(1, quantity.value), nextMax) : 1 | |||||
| } | |||||
| // ==================== 配置 / 示例 / 余额 ==================== | // ==================== 配置 / 示例 / 余额 ==================== | ||||
| async function loadConfig(silent = false) { | async function loadConfig(silent = false) { | ||||
| try { | try { | ||||
| const res = await post('/portrait/config', { | const res = await post('/portrait/config', { | ||||
| style: activeStyle.value || undefined, | style: activeStyle.value || undefined, | ||||
| model: activeModel.value || undefined, | model: activeModel.value || undefined, | ||||
| count: quantity.value, | |||||
| count: effectiveQuantity.value, | |||||
| }) | }) | ||||
| const data = res.data || {} | const data = res.data || {} | ||||
| models.value = data.models || [] | models.value = data.models || [] | ||||
| styles.value = data.styles || [] | styles.value = data.styles || [] | ||||
| ratios.value = data.ratios || [] | ratios.value = data.ratios || [] | ||||
| tips.value = data.tips || [] | tips.value = data.tips || [] | ||||
| maxCount.value = Number(data.max_count || 4) | |||||
| applyQuantityConfig(data) | |||||
| unitPoints.value = Number(data.unit_points || 0) | unitPoints.value = Number(data.unit_points || 0) | ||||
| if (!activeModel.value) activeModel.value = data.default_model || models.value[0]?.key || '' | if (!activeModel.value) activeModel.value = data.default_model || models.value[0]?.key || '' | ||||
| if (!activeRatio.value) { | if (!activeRatio.value) { | ||||
| @@ -682,7 +729,7 @@ async function handleSubmit() { | |||||
| style: activeStyle.value, | style: activeStyle.value, | ||||
| model: activeModel.value, | model: activeModel.value, | ||||
| ratio: activeRatio.value, | ratio: activeRatio.value, | ||||
| count: quantity.value, | |||||
| count: effectiveQuantity.value, | |||||
| extra_prompt: extraPrompt.value, | extra_prompt: extraPrompt.value, | ||||
| request_id: createRequestId(), | request_id: createRequestId(), | ||||
| options: { ...options }, | options: { ...options }, | ||||
| @@ -108,16 +108,16 @@ | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-6 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]"> | <div class="rounded-2xl bg-[var(--c-ffffff)] p-6 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]"> | ||||
| <!-- 未开始 --> | <!-- 未开始 --> | ||||
| <template v-if="payState === 'idle'"> | <template v-if="payState === 'idle'"> | ||||
| <UIcon name="i-lucide-qr-code" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" /> | |||||
| <div class="mb-1 text-base font-bold text-[var(--c-172033)]">微信扫码支付</div> | |||||
| <div class="text-sm text-[var(--c-a0aabb)]">选择套餐后点击「确认充值」</div> | |||||
| <div class="text-sm text-[var(--c-a0aabb)]">这里会生成支付二维码</div> | |||||
| <UIcon :name="payIdleIcon" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" /> | |||||
| <div class="mb-1 text-base font-bold text-[var(--c-172033)]">{{ payIdleTitle }}</div> | |||||
| <div class="text-sm text-[var(--c-a0aabb)]">{{ payIdleDesc1 }}</div> | |||||
| <div class="text-sm text-[var(--c-a0aabb)]">{{ payIdleDesc2 }}</div> | |||||
| </template> | </template> | ||||
| <!-- 生成中 / 待支付 / 成功 --> | <!-- 生成中 / 待支付 / 成功 --> | ||||
| <template v-else> | <template v-else> | ||||
| <div class="mb-1 text-base font-bold text-[var(--c-172033)]"> | <div class="mb-1 text-base font-bold text-[var(--c-172033)]"> | ||||
| {{ payState === 'success' ? '充值成功' : '微信扫码支付' }} | |||||
| {{ payPanelTitle }} | |||||
| </div> | </div> | ||||
| <div class="mb-4 text-sm text-[var(--c-8a95a6)]"> | <div class="mb-4 text-sm text-[var(--c-8a95a6)]"> | ||||
| {{ payStateText }} | {{ payStateText }} | ||||
| @@ -134,6 +134,11 @@ | |||||
| <span class="text-sm font-bold text-[var(--c-17a65a)]">+{{ current?.points || 0 }} 点已到账</span> | <span class="text-sm font-bold text-[var(--c-17a65a)]">+{{ current?.points || 0 }} 点已到账</span> | ||||
| </div> | </div> | ||||
| <div v-else-if="isMobileClient && payState === 'pending'" class="flex flex-col items-center gap-2"> | |||||
| <UIcon name="i-lucide-smartphone" class="size-12 text-[var(--c-0b8cff)]" /> | |||||
| <span class="text-xs font-bold text-[var(--c-0b8cff)]">等待支付完成...</span> | |||||
| </div> | |||||
| <ClientOnly v-else-if="codeUrl"> | <ClientOnly v-else-if="codeUrl"> | ||||
| <vue-qr :text="codeUrl" :size="180" :margin="8" /> | <vue-qr :text="codeUrl" :size="180" :margin="8" /> | ||||
| </ClientOnly> | </ClientOnly> | ||||
| @@ -183,7 +188,12 @@ useSeoMeta({ | |||||
| const { userInfo, isLogin, showLogin, refreshBalance } = useUser() | const { userInfo, isLogin, showLogin, refreshBalance } = useUser() | ||||
| const { post } = useApi() | const { post } = useApi() | ||||
| const { reportCv } = usePvcv() | |||||
| const toast = useToast() | const toast = useToast() | ||||
| const route = useRoute() | |||||
| const { clientId, isMobileClient, isWechatClient } = useClientDevice() | |||||
| const wxPayOpenId = useCookie('wechat_pay_open_id', { maxAge: 30 * 24 * 3600, path: '/' }) | |||||
| const WECHAT_MP_APPID = 'wxd2aa05b2249b16ce' | |||||
| const packages = ref([]) | const packages = ref([]) | ||||
| const current = ref(null) | const current = ref(null) | ||||
| @@ -202,9 +212,30 @@ const buyText = computed(() => { | |||||
| if (!current.value) return '请选择套餐' | if (!current.value) return '请选择套餐' | ||||
| return `确认充值(${current.value.price_yuan}元)` | return `确认充值(${current.value.price_yuan}元)` | ||||
| }) | }) | ||||
| const payIdleIcon = computed(() => (isMobileClient.value ? 'i-lucide-smartphone' : 'i-lucide-qr-code')) | |||||
| const payIdleTitle = computed(() => { | |||||
| if (!isMobileClient.value) return '微信扫码支付' | |||||
| return isWechatClient.value ? '微信内支付' : '手机微信支付' | |||||
| }) | |||||
| const payIdleDesc1 = computed(() => { | |||||
| if (!isMobileClient.value) return '选择套餐后点击「确认充值」' | |||||
| return isWechatClient.value ? '选择套餐后将拉起微信支付' : '选择套餐后将跳转微信支付页' | |||||
| }) | |||||
| const payIdleDesc2 = computed(() => { | |||||
| if (!isMobileClient.value) return '这里会生成支付二维码' | |||||
| return '支付完成后点数会自动到账' | |||||
| }) | |||||
| const payPanelTitle = computed(() => { | |||||
| if (payState.value === 'success') return '充值成功' | |||||
| if (!isMobileClient.value) return '微信扫码支付' | |||||
| return isWechatClient.value ? '微信内支付' : '手机微信支付' | |||||
| }) | |||||
| const payStateText = computed(() => { | const payStateText = computed(() => { | ||||
| if (payState.value === 'loading') return '正在创建订单...' | |||||
| if (payState.value === 'pending') return '请使用微信扫描二维码完成支付' | |||||
| if (payState.value === 'loading') return isMobileClient.value ? '正在创建订单并准备拉起支付...' : '正在创建订单...' | |||||
| if (payState.value === 'pending') { | |||||
| if (!isMobileClient.value) return '请使用微信扫描二维码完成支付' | |||||
| return isWechatClient.value ? '请在微信支付弹窗中完成支付' : '请在打开的微信支付页面完成支付' | |||||
| } | |||||
| if (payState.value === 'success') return '点数已到账,可以开始创作了' | if (payState.value === 'success') return '点数已到账,可以开始创作了' | ||||
| return '订单创建失败' | return '订单创建失败' | ||||
| }) | }) | ||||
| @@ -244,32 +275,140 @@ function selectPackage(pkg) { | |||||
| current.value = pkg | current.value = pkg | ||||
| } | } | ||||
| // ==================== 支付(PC 走微信 NATIVE 扫码) ==================== | |||||
| async function startPay() { | |||||
| // ==================== 支付(PC 扫码 / H5 微信支付) ==================== | |||||
| function isInvalidOpenId(value) { | |||||
| return !value || value === 'undefined' || value === 'null' | |||||
| } | |||||
| function cleanOAuthQuery() { | |||||
| if (!import.meta.client) return | |||||
| const url = new URL(window.location.href) | |||||
| url.searchParams.delete('code') | |||||
| url.searchParams.delete('state') | |||||
| window.history.replaceState({}, document.title, url.toString()) | |||||
| } | |||||
| function getWechatAuthUrl(packageId) { | |||||
| const url = new URL(window.location.href) | |||||
| url.searchParams.delete('code') | |||||
| url.searchParams.delete('state') | |||||
| const redirectUri = encodeURIComponent(url.toString()) | |||||
| return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${WECHAT_MP_APPID}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base&state=pointbuy_${packageId}#wechat_redirect` | |||||
| } | |||||
| function getQueryValue(value) { | |||||
| return Array.isArray(value) ? value[0] : value | |||||
| } | |||||
| function invokeWechatPay(payInfo) { | |||||
| if (!import.meta.client) return | |||||
| const payload = { | |||||
| appId: payInfo.appId || payInfo.appid || WECHAT_MP_APPID, | |||||
| timeStamp: String(payInfo.timeStamp || payInfo.timestamp || ''), | |||||
| nonceStr: payInfo.nonceStr || payInfo.noncestr || '', | |||||
| package: payInfo.package || (payInfo.prepay_id ? `prepay_id=${payInfo.prepay_id}` : ''), | |||||
| signType: payInfo.signType || 'RSA', | |||||
| paySign: payInfo.paySign || payInfo.paysign || '', | |||||
| } | |||||
| const onReady = () => { | |||||
| window.WeixinJSBridge.invoke('getBrandWCPayRequest', payload, (res = {}) => { | |||||
| if (res.err_msg === 'get_brand_wcpay_request:ok') { | |||||
| pollOnce() | |||||
| } else if (res.err_msg === 'get_brand_wcpay_request:cancel') { | |||||
| cancelPay() | |||||
| showMsg('支付已取消', 'warning') | |||||
| } else { | |||||
| payState.value = 'error' | |||||
| showMsg('支付失败,请重试') | |||||
| } | |||||
| }) | |||||
| } | |||||
| if (typeof window.WeixinJSBridge === 'undefined') { | |||||
| document.addEventListener('WeixinJSBridgeReady', onReady, false) | |||||
| } else { | |||||
| onReady() | |||||
| } | |||||
| } | |||||
| async function handleWechatOAuthReturn() { | |||||
| if (!import.meta.client || !isMobileClient.value || !isWechatClient.value) return | |||||
| const code = getQueryValue(route.query.code) | |||||
| const state = String(getQueryValue(route.query.state) || '') | |||||
| if (!code || !state.startsWith('pointbuy_')) return | |||||
| try { | |||||
| const res = await post('/common/getopenid', { code, client: 2 }) | |||||
| const openId = res.data?.wx_open_id || '' | |||||
| if (!openId) throw new Error('获取 openid 失败') | |||||
| wxPayOpenId.value = openId | |||||
| cleanOAuthQuery() | |||||
| const packageId = Number(state.replace('pointbuy_', '')) | |||||
| const target = packages.value.find(item => Number(item.id) === packageId) | |||||
| if (target) current.value = target | |||||
| if (current.value) await startPay({ skipOauth: true }) | |||||
| } catch (e) { | |||||
| cleanOAuthQuery() | |||||
| showMsg(e.message || '微信支付身份获取失败,请重新点击充值') | |||||
| } | |||||
| } | |||||
| async function startPay(options = {}) { | |||||
| if (!current.value) return | if (!current.value) return | ||||
| reportCv('point_recharge_click', { page_key: 'recharge' }) | |||||
| if (!isLogin.value) { | if (!isLogin.value) { | ||||
| showLogin.value = true | showLogin.value = true | ||||
| return | return | ||||
| } | } | ||||
| if (isMobileClient.value && isWechatClient.value && isInvalidOpenId(wxPayOpenId.value) && !options.skipOauth) { | |||||
| window.location.href = getWechatAuthUrl(current.value.id) | |||||
| return | |||||
| } | |||||
| payState.value = 'loading' | payState.value = 'loading' | ||||
| codeUrl.value = '' | codeUrl.value = '' | ||||
| try { | try { | ||||
| const res = await post('/point/createorder', { | |||||
| const payload = { | |||||
| package_id: current.value.id, | package_id: current.value.id, | ||||
| client: 1, // PC | |||||
| }) | |||||
| client: clientId.value, | |||||
| base_in_wechat: isWechatClient.value ? 1 : 0, | |||||
| } | |||||
| if (isMobileClient.value && isWechatClient.value) { | |||||
| payload.open_id = wxPayOpenId.value | |||||
| } | |||||
| const res = await post('/point/createorder', payload) | |||||
| const data = res.data || {} | const data = res.data || {} | ||||
| // NATIVE 支付返回二维码链接(兼容多种字段命名) | |||||
| const url = data.code_url || data.codeUrl || data.qr_code || '' | |||||
| orderNo.value = data.order_no || '' | orderNo.value = data.order_no || '' | ||||
| reportCv('point_recharge_order', { page_key: 'recharge' }) | |||||
| if (data.mode === 'wxpay_jsapi' || data.package || data.paySign) { | |||||
| payState.value = 'pending' | |||||
| startPolling() | |||||
| invokeWechatPay(data) | |||||
| return | |||||
| } | |||||
| const h5Url = data.h5_url || data.h5Url || '' | |||||
| if (data.mode === 'wxpay_h5' && h5Url) { | |||||
| payState.value = 'pending' | |||||
| startPolling() | |||||
| window.location.href = h5Url | |||||
| return | |||||
| } | |||||
| const url = data.code_url || data.codeUrl || data.qr_code || '' | |||||
| if (url) { | if (url) { | ||||
| codeUrl.value = url | codeUrl.value = url | ||||
| payState.value = 'pending' | payState.value = 'pending' | ||||
| startPolling() | startPolling() | ||||
| } else { | } else { | ||||
| payState.value = 'error' | payState.value = 'error' | ||||
| showMsg(res.msg || 'PC 端支付暂不可用') | |||||
| showMsg(res.msg || '支付暂不可用') | |||||
| } | } | ||||
| } catch (e) { | } catch (e) { | ||||
| payState.value = 'error' | payState.value = 'error' | ||||
| @@ -278,22 +417,27 @@ async function startPay() { | |||||
| } | } | ||||
| // 轮询订单状态:服务端会主动查微信并流转订单 + 幂等入账 | // 轮询订单状态:服务端会主动查微信并流转订单 + 幂等入账 | ||||
| async function pollOnce() { | |||||
| if (!orderNo.value) return | |||||
| try { | |||||
| const res = await post('/point/orderstatus', { order_no: orderNo.value }) | |||||
| const d = res.data || {} | |||||
| if (Number(d.status) === 20) { | |||||
| clearInterval(pollingTimer) | |||||
| payState.value = 'success' | |||||
| balance.points = d.balance ?? balance.points | |||||
| refreshBalance() | |||||
| reportCv('point_recharge_paid', { page_key: 'recharge' }) | |||||
| } | |||||
| } catch { | |||||
| // 单次失败不中断轮询 | |||||
| } | |||||
| } | |||||
| function startPolling() { | function startPolling() { | ||||
| clearInterval(pollingTimer) | clearInterval(pollingTimer) | ||||
| pollingTimer = setInterval(async () => { | pollingTimer = setInterval(async () => { | ||||
| if (!orderNo.value) return | |||||
| try { | |||||
| const res = await post('/point/orderstatus', { order_no: orderNo.value }) | |||||
| const d = res.data || {} | |||||
| if (Number(d.status) === 20) { | |||||
| clearInterval(pollingTimer) | |||||
| payState.value = 'success' | |||||
| balance.points = d.balance ?? balance.points | |||||
| refreshBalance() // 同步 header 点数 | |||||
| } | |||||
| } catch { | |||||
| // 单次失败不中断轮询 | |||||
| } | |||||
| await pollOnce() | |||||
| }, 2000) | }, 2000) | ||||
| } | } | ||||
| @@ -309,9 +453,10 @@ function finishPay() { | |||||
| loadBalance() | loadBalance() | ||||
| } | } | ||||
| onMounted(() => { | |||||
| loadPackages() | |||||
| loadBalance() | |||||
| onMounted(async () => { | |||||
| await loadPackages() | |||||
| await loadBalance() | |||||
| await handleWechatOAuthReturn() | |||||
| }) | }) | ||||
| watch(isLogin, async (loggedIn) => { | watch(isLogin, async (loggedIn) => { | ||||
| @@ -0,0 +1,29 @@ | |||||
| export default defineNuxtPlugin(() => { | |||||
| const router = useRouter() | |||||
| const { reportPv } = usePvcv() | |||||
| let lastFullPath = '' | |||||
| let lastReportedAt = 0 | |||||
| function track(to, from) { | |||||
| if (!to || to.fullPath === lastFullPath) return | |||||
| const now = Date.now() | |||||
| if (now - lastReportedAt < 300 && to.fullPath === lastFullPath) return | |||||
| const referrer = from?.fullPath | |||||
| ? `${window.location.origin}${from.fullPath}` | |||||
| : document.referrer | |||||
| lastFullPath = to.fullPath | |||||
| lastReportedAt = now | |||||
| reportPv(to, { referrer }) | |||||
| } | |||||
| router.afterEach((to, from) => { | |||||
| window.setTimeout(() => track(to, from), 0) | |||||
| }) | |||||
| window.setTimeout(() => { | |||||
| track(router.currentRoute.value, null) | |||||
| }, 0) | |||||
| }) | |||||
| @@ -0,0 +1,32 @@ | |||||
| const appName = process.env.APP_NAME || 'pc_nuxt' | |||||
| const host = process.env.HOST || '0.0.0.0' | |||||
| const port = process.env.PORT || '6888' | |||||
| const apiBase = process.env.API_BASE || 'https://api.aionline.cc' | |||||
| const env = { | |||||
| NODE_ENV: 'production', | |||||
| HOST: host, | |||||
| PORT: port, | |||||
| NITRO_HOST: host, | |||||
| NITRO_PORT: port, | |||||
| API_BASE: apiBase | |||||
| } | |||||
| module.exports = { | |||||
| apps: [ | |||||
| { | |||||
| name: appName, | |||||
| script: '.output/server/index.mjs', | |||||
| cwd: __dirname, | |||||
| exec_mode: 'cluster', | |||||
| instances: Number(process.env.PM2_INSTANCES || 2), | |||||
| autorestart: true, | |||||
| watch: false, | |||||
| max_memory_restart: process.env.PM2_MAX_MEMORY || '512M', | |||||
| time: true, | |||||
| out_file: `./logs/${appName}-out.log`, | |||||
| error_file: `./logs/${appName}-error.log`, | |||||
| env, | |||||
| env_production: env | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -0,0 +1,89 @@ | |||||
| #!/usr/bin/env bash | |||||
| set -Eeuo pipefail | |||||
| APP_NAME="${APP_NAME:-pc_nuxt}" | |||||
| BRANCH="${BRANCH:-}" | |||||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |||||
| cd "$SCRIPT_DIR" | |||||
| log() { | |||||
| printf '\n[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | |||||
| } | |||||
| require_command() { | |||||
| if ! command -v "$1" >/dev/null 2>&1; then | |||||
| log "Missing command: $1" | |||||
| exit 1 | |||||
| fi | |||||
| } | |||||
| ensure_clean_worktree() { | |||||
| if [ -n "$(git status --porcelain)" ]; then | |||||
| log "Working tree is not clean. Commit/stash/clean changes before deploy." | |||||
| git status --short | |||||
| exit 1 | |||||
| fi | |||||
| } | |||||
| resolve_branch() { | |||||
| if [ -z "$BRANCH" ]; then | |||||
| BRANCH="$(git rev-parse --abbrev-ref HEAD)" | |||||
| fi | |||||
| if [ "$BRANCH" = "HEAD" ]; then | |||||
| log "Cannot detect git branch. Please run with BRANCH=main ./restart.sh" | |||||
| exit 1 | |||||
| fi | |||||
| } | |||||
| pull_latest_code() { | |||||
| log "Pull latest code from origin/$BRANCH" | |||||
| ensure_clean_worktree | |||||
| git fetch origin "$BRANCH" | |||||
| git pull --ff-only origin "$BRANCH" | |||||
| } | |||||
| install_dependencies() { | |||||
| log "Install dependencies" | |||||
| pnpm install --frozen-lockfile | |||||
| } | |||||
| build_app() { | |||||
| log "Build Nuxt app" | |||||
| pnpm run build | |||||
| if [ ! -f ".output/server/index.mjs" ]; then | |||||
| log "Build output missing: .output/server/index.mjs" | |||||
| exit 1 | |||||
| fi | |||||
| } | |||||
| restart_app() { | |||||
| log "Reload app with pm2 ecosystem config" | |||||
| mkdir -p logs | |||||
| APP_NAME="$APP_NAME" pm2 startOrReload ecosystem.config.cjs --env production --update-env | |||||
| pm2 save >/dev/null 2>&1 || true | |||||
| } | |||||
| main() { | |||||
| require_command git | |||||
| require_command node | |||||
| require_command pnpm | |||||
| require_command pm2 | |||||
| resolve_branch | |||||
| log "Deploy start: $APP_NAME" | |||||
| log "Node: $(node -v 2>/dev/null || echo 'unknown')" | |||||
| log "pnpm: $(pnpm -v)" | |||||
| log "pm2: $(pm2 -v)" | |||||
| pull_latest_code | |||||
| install_dependencies | |||||
| build_app | |||||
| restart_app | |||||
| log "Deploy done: $APP_NAME" | |||||
| } | |||||
| main "$@" | |||||