| @@ -0,0 +1,24 @@ | |||||
| # Nuxt dev/build outputs | |||||
| .output | |||||
| .data | |||||
| .nuxt | |||||
| .nitro | |||||
| .cache | |||||
| dist | |||||
| # Node dependencies | |||||
| node_modules | |||||
| # Logs | |||||
| logs | |||||
| *.log | |||||
| # Misc | |||||
| .DS_Store | |||||
| .fleet | |||||
| .idea | |||||
| # Local env files | |||||
| .env | |||||
| .env.* | |||||
| !.env.example | |||||
| @@ -0,0 +1,126 @@ | |||||
| # pc_nuxt | |||||
| 奇想宇宙 PC 端站点,基于 Nuxt 4 重构,支持 SSR / SEO。 | |||||
| ## 技术栈 | |||||
| | 类别 | 技术 | | |||||
| | --- | --- | | |||||
| | 框架 | Nuxt 4.5 + Vue 3.5 | | |||||
| | 渲染 | SSR(服务端渲染,开箱即用) | | |||||
| | 样式 | TailwindCSS | | |||||
| | 包管理 | pnpm | | |||||
| | 语言 | JavaScript(无 TypeScript) | | |||||
| | 后端 | ThinkJS(ai_api / ai_server,不动) | | |||||
| ## 前置条件 | |||||
| - **Node.js** >= 22.19.0(Nuxt 4.5 强制要求) | |||||
| - **pnpm**(全局安装:`npm install -g pnpm`) | |||||
| ```bash | |||||
| # 安装/切换 Node(如用 nvm-windows) | |||||
| nvm install 22.19.0 | |||||
| nvm use 22.19.0 | |||||
| ``` | |||||
| ## 快速开始 | |||||
| ```bash | |||||
| # 安装依赖 | |||||
| pnpm install | |||||
| # 启动开发服务器(监听局域网,端口 6888) | |||||
| pnpm run dev | |||||
| # → http://localhost:6888 | |||||
| # → http://<你的局域网IP>:6888 | |||||
| # 生产构建 | |||||
| pnpm run build | |||||
| # 预览生产构建 | |||||
| pnpm run preview | |||||
| ``` | |||||
| ## 目录结构 | |||||
| ``` | |||||
| pc_nuxt/ | |||||
| ├── app/ | |||||
| │ ├── app.vue # 根组件 | |||||
| │ ├── assets/css/main.css # TailwindCSS 入口 | |||||
| │ ├── composables/useApi.js # ai_api 接口封装(自动导入) | |||||
| │ ├── layouts/default.vue # 默认布局 | |||||
| │ └── pages/ # 页面(文件路由) | |||||
| │ └── index.vue # 首页 | |||||
| ├── server/api/[...].js # 服务端 API 代理 → ai_api | |||||
| ├── nuxt.config.js # Nuxt 4 配置 | |||||
| ├── package.json | |||||
| └── pnpm-lock.yaml | |||||
| ``` | |||||
| ## 项目关系 | |||||
| ``` | |||||
| aionline/ | |||||
| ├── pc_nuxt/ ← 本仓库(PC 端 Nuxt 4 重构) | |||||
| ├── ai_api/ ← 主 API 后端(ThinkJS,不修改) | |||||
| ├── ai_server/ ← AI 能力后端(ThinkJS,不修改) | |||||
| ├── pc/ ← 原 PC 端(Vue2 + Webpack4,逐步废弃) | |||||
| ├── m/ ← 移动端 H5 | |||||
| ├── ai_admin/ ← 管理后台 | |||||
| ├── ai_uniapp/ ← 跨端应用 | |||||
| └── ws/ ← 设备管理 | |||||
| ``` | |||||
| 各子项目**相互独立**,不共享 `node_modules`。 | |||||
| ## 对接后端 | |||||
| ### 接口封装:useApi() | |||||
| ```js | |||||
| const api = useApi() | |||||
| // GET 请求 | |||||
| const res = await api.get('/user/info', { id: 1 }) | |||||
| // res = { code: 0, data: {...}, list: [...], msg: 'ok' } | |||||
| // POST 请求(自动转 x-www-form-urlencoded) | |||||
| const res = await api.post('/user/login', { username, password }) | |||||
| ``` | |||||
| ### 响应约定(沿用 ThinkJS) | |||||
| | code | 含义 | | |||||
| | --- | --- | | |||||
| | `0` | 成功,数据在 `data` / `list` 字段 | | |||||
| | `1000` | 业务异常,错误信息在 `msg` | | |||||
| | `1009` | 登录失效,客户端自动跳转 `/login` | | |||||
| ### 配置后端地址 | |||||
| ```bash | |||||
| # 环境变量(优先级最高) | |||||
| API_BASE=http://192.168.1.100:8360 pnpm run dev | |||||
| # 或直接修改 nuxt.config.js 中的 runtimeConfig.public.apiBase | |||||
| ``` | |||||
| ### 服务端代理 | |||||
| 前端调 `/api/**` 会自动代理到 `ai_api`,路径示例: | |||||
| ``` | |||||
| $fetch('/api/user/info') | |||||
| → server/api/[...].js 代理转发 | |||||
| → http://localhost:8360/user/info(自动附带 cookie) | |||||
| ``` | |||||
| ## 约定 | |||||
| - **纯 JS**:不用 TypeScript,`.vue` 文件使用 `<script setup>` | |||||
| - **SEO**:每个页面必须写 `useSeoMeta()` 设置 title / description | |||||
| - **样式**:统一用 TailwindCSS utility class,避免写裸 CSS | |||||
| - **就近一致**:复用 composables / 组件,不引入功能重复的新库 | |||||
| - **端口 6888**:开发服务器,监听所有网口(支持局域网访问) | |||||
| @@ -0,0 +1,7 @@ | |||||
| <template> | |||||
| <div> | |||||
| <NuxtLayout> | |||||
| <NuxtPage /> | |||||
| </NuxtLayout> | |||||
| </div> | |||||
| </template> | |||||
| @@ -0,0 +1,37 @@ | |||||
| /* 奇想宇宙 AIonline - PC 端全局样式 */ | |||||
| /* @nuxt/ui v4 内置 TailwindCSS v4,需在入口显式导入 */ | |||||
| @import "tailwindcss"; | |||||
| /* 全局字体与基础样式 */ | |||||
| body { | |||||
| font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif; | |||||
| -webkit-font-smoothing: antialiased; | |||||
| -moz-osx-font-smoothing: grayscale; | |||||
| } | |||||
| /* 页面过渡动画 */ | |||||
| .page-enter-active, | |||||
| .page-leave-active { | |||||
| transition: opacity 0.2s ease; | |||||
| } | |||||
| .page-enter-from, | |||||
| .page-leave-to { | |||||
| opacity: 0; | |||||
| } | |||||
| /* 滚动条美化 */ | |||||
| ::-webkit-scrollbar { | |||||
| width: 6px; | |||||
| height: 6px; | |||||
| } | |||||
| ::-webkit-scrollbar-track { | |||||
| background: transparent; | |||||
| } | |||||
| ::-webkit-scrollbar-thumb { | |||||
| background: #cbd5e1; | |||||
| border-radius: 3px; | |||||
| } | |||||
| ::-webkit-scrollbar-thumb:hover { | |||||
| background: #94a3b8; | |||||
| } | |||||
| @@ -0,0 +1,318 @@ | |||||
| <template> | |||||
| <div class="fixed inset-0 z-[999] flex items-center justify-center"> | |||||
| <!-- 遮罩 --> | |||||
| <div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="$emit('close')" /> | |||||
| <!-- 弹窗 --> | |||||
| <div class="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden"> | |||||
| <!-- 关闭按钮 --> | |||||
| <button | |||||
| class="absolute top-4 right-4 z-10 w-8 h-8 flex items-center justify-center rounded-full text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors cursor-pointer" | |||||
| @click="$emit('close')" | |||||
| > | |||||
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |||||
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> | |||||
| </svg> | |||||
| </button> | |||||
| <!-- 标题 --> | |||||
| <div class="px-8 pt-8 pb-4 text-center"> | |||||
| <h2 class="text-xl font-bold text-slate-900">登录奇想宇宙</h2> | |||||
| <p class="mt-1 text-sm text-slate-500">登录后享受更多 AI 创作能力</p> | |||||
| </div> | |||||
| <!-- Tab 切换 --> | |||||
| <div class="px-8"> | |||||
| <div class="flex border-b border-slate-200"> | |||||
| <button | |||||
| :class="[ | |||||
| 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer', | |||||
| activeTab === 'qr' | |||||
| ? 'text-indigo-600 border-indigo-500' | |||||
| : 'text-slate-400 border-transparent hover:text-slate-600', | |||||
| ]" | |||||
| @click="activeTab = 'qr'" | |||||
| > | |||||
| 微信扫码登录 | |||||
| </button> | |||||
| <button | |||||
| :class="[ | |||||
| 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer', | |||||
| activeTab === 'phone' | |||||
| ? 'text-indigo-600 border-indigo-500' | |||||
| : 'text-slate-400 border-transparent hover:text-slate-600', | |||||
| ]" | |||||
| @click="activeTab = 'phone'" | |||||
| > | |||||
| 手机号登录 | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 微信扫码登录 --> | |||||
| <div v-if="activeTab === 'qr'" class="px-8 py-6"> | |||||
| <div class="flex flex-col items-center"> | |||||
| <!-- 二维码区域 --> | |||||
| <div | |||||
| class="relative w-48 h-48 rounded-xl border border-slate-200 flex items-center justify-center bg-white" | |||||
| > | |||||
| <!-- 加载中 --> | |||||
| <div v-if="qrLoading" class="flex flex-col items-center gap-2"> | |||||
| <svg class="w-8 h-8 text-indigo-500 animate-spin" fill="none" viewBox="0 0 24 24"> | |||||
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> | |||||
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> | |||||
| </svg> | |||||
| <span class="text-xs text-slate-400">获取二维码...</span> | |||||
| </div> | |||||
| <!-- 二维码过期 --> | |||||
| <div | |||||
| v-else-if="qrExpired" | |||||
| class="flex flex-col items-center gap-3 cursor-pointer" | |||||
| @click="refreshQR" | |||||
| > | |||||
| <svg class="w-10 h-10 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |||||
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4v1m6 11h2m-6 0h-2m4-8h2m-8 0H6m5 5h2m-4 0h2m2 4h-2m2-4h2" /> | |||||
| </svg> | |||||
| <span class="text-sm text-slate-400">二维码已过期</span> | |||||
| <span class="text-xs text-indigo-500">点击刷新</span> | |||||
| </div> | |||||
| <!-- 二维码 --> | |||||
| <vue-qr | |||||
| v-else-if="qrData.url" | |||||
| :text="qrData.url" | |||||
| :size="180" | |||||
| :margin="8" | |||||
| :logo-image="qrLogo" | |||||
| :logo-size="36" | |||||
| /> | |||||
| </div> | |||||
| <p class="mt-4 text-sm text-slate-500 text-center"> | |||||
| 请使用微信扫描二维码完成登录 | |||||
| </p> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 手机号登录 --> | |||||
| <div v-if="activeTab === 'phone'" class="px-8 py-6"> | |||||
| <div class="space-y-4"> | |||||
| <!-- 手机号 --> | |||||
| <div> | |||||
| <label class="block text-xs font-medium text-slate-600 mb-1.5">手机号</label> | |||||
| <input | |||||
| v-model="phoneForm.phone" | |||||
| type="tel" | |||||
| maxlength="11" | |||||
| placeholder="请输入手机号" | |||||
| class="w-full px-3 py-2.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors" | |||||
| @input="phoneForm.phone = phoneForm.phone.replace(/\D/g, '')" | |||||
| /> | |||||
| </div> | |||||
| <!-- 验证码 --> | |||||
| <div> | |||||
| <label class="block text-xs font-medium text-slate-600 mb-1.5">验证码</label> | |||||
| <div class="flex gap-3"> | |||||
| <input | |||||
| v-model="phoneForm.code" | |||||
| type="text" | |||||
| maxlength="6" | |||||
| placeholder="请输入验证码" | |||||
| class="flex-1 px-3 py-2.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors" | |||||
| /> | |||||
| <button | |||||
| :disabled="phoneForm.sendingCode || countdown > 0" | |||||
| :class="[ | |||||
| 'shrink-0 px-4 py-2.5 text-sm font-medium rounded-lg transition-colors cursor-pointer', | |||||
| countdown > 0 | |||||
| ? 'text-slate-400 bg-slate-100 cursor-not-allowed' | |||||
| : 'text-indigo-600 bg-indigo-50 hover:bg-indigo-100', | |||||
| ]" | |||||
| @click="sendCode" | |||||
| > | |||||
| {{ countdown > 0 ? `${countdown}s` : '获取验证码' }} | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 错误提示 --> | |||||
| <p v-if="phoneForm.error" class="text-xs text-red-500">{{ phoneForm.error }}</p> | |||||
| <!-- 登录按钮 --> | |||||
| <button | |||||
| :disabled="phoneForm.loading || !phoneForm.phone || !phoneForm.code" | |||||
| class="w-full py-2.5 text-sm font-semibold text-white bg-indigo-500 hover:bg-indigo-600 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-lg transition-colors cursor-pointer" | |||||
| @click="loginByPhone" | |||||
| > | |||||
| <span v-if="phoneForm.loading" class="inline-flex items-center gap-2"> | |||||
| <svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"> | |||||
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> | |||||
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> | |||||
| </svg> | |||||
| 登录中... | |||||
| </span> | |||||
| <span v-else>登录</span> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 底部协议 --> | |||||
| <div class="px-8 pb-6 text-center"> | |||||
| <p class="text-xs text-slate-400"> | |||||
| 登录即表示同意 | |||||
| <a href="#" class="text-indigo-500 hover:underline">《服务条款》</a> | |||||
| 和 | |||||
| <a href="#" class="text-indigo-500 hover:underline">《隐私政策》</a> | |||||
| </p> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| import VueQr from 'vue-qr' | |||||
| const emit = defineEmits(['close', 'login-success']) | |||||
| const { post } = useApi() | |||||
| const { setLogin } = useUser() | |||||
| // Tab | |||||
| const activeTab = ref('qr') | |||||
| // ===== 微信扫码 ===== | |||||
| const qrLoading = ref(true) | |||||
| const qrExpired = ref(false) | |||||
| const qrData = ref({ ticket: '', url: '' }) | |||||
| const qrLogo = '' // 可替换为 logo URL | |||||
| let qrTimer = null | |||||
| // 获取二维码 | |||||
| async function refreshQR() { | |||||
| qrLoading.value = true | |||||
| qrExpired.value = false | |||||
| try { | |||||
| const res = await post('/user/getloginqr') | |||||
| if (res.code === 0 && res.data) { | |||||
| qrData.value = res.data | |||||
| qrLoading.value = false | |||||
| startPolling() | |||||
| } else { | |||||
| qrLoadError() | |||||
| } | |||||
| } catch { | |||||
| qrLoadError() | |||||
| } | |||||
| } | |||||
| function qrLoadError() { | |||||
| qrLoading.value = false | |||||
| qrExpired.value = true | |||||
| } | |||||
| // 轮询扫码结果 | |||||
| function startPolling() { | |||||
| clearInterval(qrTimer) | |||||
| qrTimer = setInterval(async () => { | |||||
| try { | |||||
| const res = await post('/user/scanquery', { ticket: qrData.value.ticket }) | |||||
| if (res.code === 0 && res.data?.token) { | |||||
| clearInterval(qrTimer) | |||||
| handleLoginSuccess(res.data) | |||||
| } else if (res.code === 1010) { | |||||
| // 二维码过期 | |||||
| clearInterval(qrTimer) | |||||
| qrExpired.value = true | |||||
| } | |||||
| } catch { | |||||
| // 轮询失败不中断 | |||||
| } | |||||
| }, 1000) | |||||
| } | |||||
| // ===== 手机号登录 ===== | |||||
| const phoneForm = reactive({ | |||||
| phone: '', | |||||
| code: '', | |||||
| sendingCode: false, | |||||
| loading: false, | |||||
| error: '', | |||||
| }) | |||||
| const countdown = ref(0) | |||||
| let countdownTimer = null | |||||
| // 发送验证码 | |||||
| async function sendCode() { | |||||
| if (!phoneForm.phone || phoneForm.phone.length !== 11) { | |||||
| phoneForm.error = '请输入正确的11位手机号' | |||||
| return | |||||
| } | |||||
| phoneForm.error = '' | |||||
| phoneForm.sendingCode = true | |||||
| try { | |||||
| const res = await post('/common/sendcode', { | |||||
| phone: phoneForm.phone, | |||||
| type: 3, | |||||
| }) | |||||
| if (res.code === 0) { | |||||
| countdown.value = 60 | |||||
| countdownTimer = setInterval(() => { | |||||
| countdown.value-- | |||||
| if (countdown.value <= 0) { | |||||
| clearInterval(countdownTimer) | |||||
| } | |||||
| }, 1000) | |||||
| } else { | |||||
| phoneForm.error = res.msg || '发送验证码失败' | |||||
| } | |||||
| } catch (e) { | |||||
| phoneForm.error = e.message || '发送验证码失败' | |||||
| } finally { | |||||
| phoneForm.sendingCode = false | |||||
| } | |||||
| } | |||||
| // 手机号登录 | |||||
| async function loginByPhone() { | |||||
| if (!phoneForm.phone || !phoneForm.code) return | |||||
| phoneForm.error = '' | |||||
| phoneForm.loading = true | |||||
| try { | |||||
| const res = await post('/user/login_phone', { | |||||
| phone: phoneForm.phone, | |||||
| code: phoneForm.code, | |||||
| }) | |||||
| if (res.code === 0 && res.data?.token) { | |||||
| handleLoginSuccess(res.data) | |||||
| } else { | |||||
| phoneForm.error = res.msg || '登录失败' | |||||
| } | |||||
| } catch (e) { | |||||
| phoneForm.error = e.message || '登录失败' | |||||
| } finally { | |||||
| phoneForm.loading = false | |||||
| } | |||||
| } | |||||
| // ===== 公共 ===== | |||||
| function handleLoginSuccess(data) { | |||||
| setLogin(data) | |||||
| emit('login-success', data) | |||||
| } | |||||
| // 初始化 | |||||
| onMounted(() => { | |||||
| refreshQR() | |||||
| }) | |||||
| onUnmounted(() => { | |||||
| clearInterval(qrTimer) | |||||
| clearInterval(countdownTimer) | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,150 @@ | |||||
| /** | |||||
| * 统一 API 请求封装,完全对齐 ai_uniapp_v2/utils/request.js | |||||
| * | |||||
| * 请求路径走 /api 前缀 → server/api/[...].js 服务端代理 | |||||
| * /api/user/info → 代理到 https://api.jiefuku.com/user/info | |||||
| * | |||||
| * 自动注入(对齐 V2): | |||||
| * Header: { token, timestr, nonce } | |||||
| * Body: { token, uid, mac, base_timestamp, client:1, source, client_ios, version, version_code } | |||||
| * | |||||
| * 后端响应规范: | |||||
| * { code: 0, data: {...}, list: [...], msg: '...' } // 成功 | |||||
| * { code: 1000, data: null, msg: '...' } // 业务异常 | |||||
| * { code: 1009, data: null, msg: '...' } // 登录失效 | |||||
| */ | |||||
| import md5 from '~/utils/md5' | |||||
| export function useApi() { | |||||
| const tokenCookie = useCookie('token', { path: '/' }) | |||||
| const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' }) | |||||
| const openIdCookie = useCookie('open_id', { path: '/' }) | |||||
| const sourceCookie = useCookie('source', { path: '/' }) | |||||
| // mac 缺失时自动生成 | |||||
| if (import.meta.client && !macCookie.value) { | |||||
| macCookie.value = generateUUID() | |||||
| } | |||||
| function generateUUID() { | |||||
| return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { | |||||
| const r = (Math.random() * 16) | 0 | |||||
| const v = c === 'x' ? r : (r & 0x3) | 0x8 | |||||
| return v.toString(16) | |||||
| }) | |||||
| } | |||||
| /** | |||||
| * 生成 nonce + timestr(对齐 V2 getNonce) | |||||
| */ | |||||
| function getNonce() { | |||||
| const timeStr = Date.now().toString() | |||||
| const str = md5(timeStr) | |||||
| const nonce = str.substring(4, 14) | |||||
| return { timeStr, nonce } | |||||
| } | |||||
| /** | |||||
| * 发起请求 | |||||
| */ | |||||
| async function request(path, options = {}) { | |||||
| const { method = 'GET', params, body } = options | |||||
| // 走 /api 代理,对齐 V2 H5 端的 BASE_URL = '/api' | |||||
| const url = '/api' + path | |||||
| const timestamp = Math.floor(Date.now() / 1000) | |||||
| const { timeStr, nonce } = getNonce() | |||||
| // Header 签名(对齐 V2 header 注入) | |||||
| const reqHeaders = { | |||||
| 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', | |||||
| timestr: timeStr, | |||||
| nonce, | |||||
| } | |||||
| const token = tokenCookie.value | |||||
| if (token) reqHeaders.token = token | |||||
| // Body 公共参数(对齐 V2 data 注入) | |||||
| const commonParams = { | |||||
| token: token || '', | |||||
| uid: openIdCookie.value || '', | |||||
| mac: macCookie.value || '', | |||||
| base_timestamp: timestamp, | |||||
| client: 1, | |||||
| source: sourceCookie.value || '', | |||||
| client_ios: 0, | |||||
| version: '1.0.0', | |||||
| version_code: 1, | |||||
| } | |||||
| const fetchOptions = { | |||||
| method, | |||||
| headers: reqHeaders, | |||||
| onResponseError({ response }) { | |||||
| console.error('[useApi] HTTP Error', response.status, url) | |||||
| }, | |||||
| } | |||||
| if (method === 'GET') { | |||||
| // GET: 参数拼接到 URL | |||||
| const allParams = { ...commonParams, ...(params || {}) } | |||||
| // 去除空字符串 | |||||
| Object.keys(allParams).forEach((k) => { | |||||
| if (allParams[k] === '') delete allParams[k] | |||||
| }) | |||||
| const queryStr = new URLSearchParams(allParams).toString() | |||||
| fetchOptions.query = allParams | |||||
| // GET 请求通过 server proxy 时,参数在 query 里 | |||||
| const finalUrl = url + (queryStr ? '?' + queryStr : '') | |||||
| const result = await $fetch(finalUrl, fetchOptions) | |||||
| return handleResult(result) | |||||
| } else { | |||||
| // POST: body 为 x-www-form-urlencoded | |||||
| const postBody = { ...commonParams, ...(body || {}) } | |||||
| // 去除空字符串 | |||||
| Object.keys(postBody).forEach((k) => { | |||||
| if (postBody[k] === '') delete postBody[k] | |||||
| }) | |||||
| fetchOptions.body = new URLSearchParams(postBody).toString() | |||||
| const result = await $fetch(url, fetchOptions) | |||||
| return handleResult(result) | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 统一处理响应 | |||||
| */ | |||||
| function handleResult(result) { | |||||
| if (result.code === 1009) { | |||||
| // 登录失效 → 触发登录弹窗 | |||||
| if (import.meta.client) { | |||||
| const { logout } = useUser() | |||||
| logout() | |||||
| const showLogin = useState('showLogin', () => false) | |||||
| showLogin.value = true | |||||
| } | |||||
| const err = new Error(result.msg || '登录已失效,请重新登录') | |||||
| err.code = 1009 | |||||
| throw err | |||||
| } | |||||
| if (result.code !== 0) { | |||||
| const err = new Error(result.msg || '请求失败') | |||||
| err.code = result.code || 1000 | |||||
| throw err | |||||
| } | |||||
| return result | |||||
| } | |||||
| return { | |||||
| /** GET 请求 */ | |||||
| get: (path, params) => request(path, { method: 'GET', params }), | |||||
| /** POST 请求 (x-www-form-urlencoded) */ | |||||
| post: (path, body) => request(path, { method: 'POST', body }), | |||||
| /** 通用请求 */ | |||||
| request, | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,120 @@ | |||||
| /** | |||||
| * 图片上传 composable,完全对齐 ai_uniapp_v2/utils/upload.js | |||||
| * | |||||
| * - POST multipart/form-data 到 /api/common/uploadpic(走服务端代理) | |||||
| * - Header: { token, timestr, nonce } | |||||
| * - FormData: { file, token, mac, client:1, source, version, version_code, upload_type:1 } | |||||
| */ | |||||
| import md5 from '~/utils/md5' | |||||
| export function useUpload() { | |||||
| const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' }) | |||||
| const tokenCookie = useCookie('token', { path: '/' }) | |||||
| const openIdCookie = useCookie('open_id', { path: '/' }) | |||||
| const sourceCookie = useCookie('source', { path: '/' }) | |||||
| function getNonce() { | |||||
| const timeStr = Date.now().toString() | |||||
| const str = md5(timeStr) | |||||
| const nonce = str.substring(4, 14) | |||||
| return { timeStr, nonce } | |||||
| } | |||||
| /** | |||||
| * 上传图片 | |||||
| * @param {File} file - 浏览器 File 对象 | |||||
| * @returns {Promise<{code: number, data: {url: string, width: number, height: number}, msg: string}>} | |||||
| */ | |||||
| async function uploadImage(file) { | |||||
| if (!file) { | |||||
| throw new Error('请选择文件') | |||||
| } | |||||
| const timestamp = Math.floor(Date.now() / 1000) | |||||
| const { timeStr, nonce } = getNonce() | |||||
| const formData = new FormData() | |||||
| // 文件字段(最先添加) | |||||
| formData.append('file', file) | |||||
| // 公共参数(对齐 V2 upload.js) | |||||
| formData.append('token', tokenCookie.value || '') | |||||
| formData.append('uid', openIdCookie.value || '') | |||||
| formData.append('mac', macCookie.value || '') | |||||
| formData.append('base_timestamp', timestamp) | |||||
| formData.append('client', '1') | |||||
| formData.append('source', sourceCookie.value || '') | |||||
| formData.append('client_ios', '0') | |||||
| formData.append('version', '1.0.0') | |||||
| formData.append('version_code', '1') | |||||
| formData.append('upload_type', '1') | |||||
| // 走 /api 代理,对齐 V2 | |||||
| const url = '/api/common/uploadpic' | |||||
| try { | |||||
| const result = await $fetch(url, { | |||||
| method: 'POST', | |||||
| headers: { | |||||
| token: tokenCookie.value || '', | |||||
| timestr: timeStr, | |||||
| nonce, | |||||
| }, | |||||
| body: formData, | |||||
| // 不设置 Content-Type,让浏览器自动设置 multipart boundary | |||||
| }) | |||||
| if (result.code === 0) { | |||||
| return result | |||||
| } | |||||
| if (result.code === 1009) { | |||||
| const { logout } = useUser() | |||||
| logout() | |||||
| const showLogin = useState('showLogin', () => false) | |||||
| showLogin.value = true | |||||
| } | |||||
| throw new Error(result.msg || '上传失败') | |||||
| } catch (e) { | |||||
| if (e.data) { | |||||
| const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data | |||||
| if (data && data.code !== undefined) { | |||||
| throw new Error(data.msg || '上传失败') | |||||
| } | |||||
| } | |||||
| throw e | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 打开文件选择器并上传 | |||||
| */ | |||||
| function selectAndUpload(options = {}) { | |||||
| const { accept = 'image/*' } = options | |||||
| return new Promise((resolve, reject) => { | |||||
| const input = document.createElement('input') | |||||
| input.type = 'file' | |||||
| input.accept = accept | |||||
| input.onchange = async () => { | |||||
| const file = input.files[0] | |||||
| if (!file) { | |||||
| reject(new Error('未选择文件')) | |||||
| return | |||||
| } | |||||
| try { | |||||
| const result = await uploadImage(file) | |||||
| resolve(result) | |||||
| } catch (e) { | |||||
| reject(e) | |||||
| } | |||||
| } | |||||
| input.click() | |||||
| }) | |||||
| } | |||||
| return { | |||||
| uploadImage, | |||||
| selectAndUpload, | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,122 @@ | |||||
| /** | |||||
| * 用户状态管理 composable | |||||
| * | |||||
| * 管理:token、用户信息、登录状态、登录弹窗显隐 | |||||
| * Token 持久化:Cookie(SSR 安全,Nuxt useCookie 自动同步服务端/客户端) | |||||
| */ | |||||
| export function useUser() { | |||||
| const token = useCookie('token', { | |||||
| maxAge: 7 * 24 * 3600, | |||||
| path: '/', | |||||
| }) | |||||
| const userInfo = useState('userInfo', () => ({ | |||||
| user_id: null, | |||||
| user_name: '', | |||||
| avatar: '', | |||||
| nickname: '', | |||||
| open_id: '', | |||||
| vip_type: 0, | |||||
| balance: 0, | |||||
| })) | |||||
| const isLogin = computed(() => !!token.value && !!userInfo.value.user_id) | |||||
| // 登录弹窗全局状态(任何组件都可以触发) | |||||
| const showLogin = useState('showLogin', () => false) | |||||
| /** | |||||
| * 设置登录态 | |||||
| */ | |||||
| function setLogin(data) { | |||||
| token.value = data.token | |||||
| userInfo.value = { | |||||
| user_id: data.user_id, | |||||
| user_name: data.user_name || data.nickname || '', | |||||
| avatar: data.avatar || '', | |||||
| nickname: data.nickname || data.user_name || '', | |||||
| open_id: data.open_id || '', | |||||
| vip_type: data.vip_type || 0, | |||||
| balance: data.balance || 0, | |||||
| } | |||||
| // 同步写入 open_id Cookie(供 useApi 读取 uid) | |||||
| if (data.open_id) { | |||||
| const openIdCookie = useCookie('open_id', { path: '/' }) | |||||
| openIdCookie.value = data.open_id | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 退出登录 | |||||
| */ | |||||
| function logout() { | |||||
| token.value = null | |||||
| userInfo.value = { | |||||
| user_id: null, | |||||
| user_name: '', | |||||
| avatar: '', | |||||
| nickname: '', | |||||
| open_id: '', | |||||
| vip_type: 0, | |||||
| balance: 0, | |||||
| } | |||||
| // 清除 open_id Cookie | |||||
| const openIdCookie = useCookie('open_id', { path: '/' }) | |||||
| openIdCookie.value = null | |||||
| } | |||||
| /** | |||||
| * 刷新用户信息 | |||||
| */ | |||||
| async function refreshUserInfo() { | |||||
| if (!token.value) return | |||||
| try { | |||||
| const { post } = useApi() | |||||
| const res = await post('/user/getuserinfo') | |||||
| if (res.code === 0 && res.data) { | |||||
| userInfo.value = { | |||||
| ...userInfo.value, | |||||
| ...res.data, | |||||
| } | |||||
| } | |||||
| } catch (e) { | |||||
| // 获取失败不中断 | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 初始化:如果有 token 但无 userInfo,自动刷新 | |||||
| */ | |||||
| async function init() { | |||||
| if (token.value && !userInfo.value.user_id) { | |||||
| await refreshUserInfo() | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 处理 1009 鉴权失效(由 useApi 调用) | |||||
| */ | |||||
| function handleAuthError() { | |||||
| if (import.meta.client) { | |||||
| logout() | |||||
| showLogin.value = true | |||||
| } | |||||
| } | |||||
| // 客户端初始化 | |||||
| if (import.meta.client) { | |||||
| init() | |||||
| } | |||||
| return { | |||||
| token, | |||||
| userInfo, | |||||
| isLogin, | |||||
| showLogin, | |||||
| setLogin, | |||||
| logout, | |||||
| refreshUserInfo, | |||||
| handleAuthError, | |||||
| init, | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,93 @@ | |||||
| <template> | |||||
| <div class="min-h-screen flex flex-col bg-slate-50"> | |||||
| <!-- 顶部导航 --> | |||||
| <header class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-slate-200/60"> | |||||
| <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | |||||
| <div class="flex items-center justify-between h-16"> | |||||
| <!-- Logo --> | |||||
| <NuxtLink to="/" class="flex items-center gap-2 shrink-0"> | |||||
| <div class="w-8 h-8 rounded-lg bg-indigo-500 flex items-center justify-center"> | |||||
| <span class="text-white font-bold text-sm">奇</span> | |||||
| </div> | |||||
| <span class="text-lg font-semibold text-slate-800 hidden sm:block">奇想宇宙</span> | |||||
| </NuxtLink> | |||||
| <!-- 导航链接 --> | |||||
| <nav class="hidden md:flex items-center gap-1"> | |||||
| <NuxtLink | |||||
| v-for="item in navItems" | |||||
| :key="item.to" | |||||
| :to="item.to" | |||||
| class="px-3 py-2 text-sm rounded-lg text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 transition-colors duration-200" | |||||
| active-class="text-indigo-600 bg-indigo-50 font-medium" | |||||
| > | |||||
| {{ item.label }} | |||||
| </NuxtLink> | |||||
| </nav> | |||||
| <!-- 右侧操作 --> | |||||
| <div class="flex items-center gap-3"> | |||||
| <template v-if="isLogin"> | |||||
| <span class="text-xs text-slate-500 hidden sm:inline"> | |||||
| 余额 <span class="text-indigo-600 font-semibold">{{ userInfo.balance }}</span> 点 | |||||
| </span> | |||||
| <NuxtLink | |||||
| to="/works" | |||||
| class="px-3 py-2 text-sm text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors duration-200" | |||||
| > | |||||
| 我的作品 | |||||
| </NuxtLink> | |||||
| <div class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center"> | |||||
| <span class="text-indigo-600 text-xs font-bold"> | |||||
| {{ (userInfo.nickname || userInfo.user_name || '用')[0] }} | |||||
| </span> | |||||
| </div> | |||||
| </template> | |||||
| <button | |||||
| v-else | |||||
| class="px-4 py-2 text-sm font-medium text-white bg-indigo-500 hover:bg-indigo-600 rounded-lg transition-colors duration-200 cursor-pointer" | |||||
| @click="showLogin = true" | |||||
| > | |||||
| 登录 | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </header> | |||||
| <!-- 主体内容 --> | |||||
| <main class="flex-1"> | |||||
| <slot /> | |||||
| </main> | |||||
| <!-- 页脚 --> | |||||
| <footer class="border-t border-slate-200 bg-white py-8 mt-auto"> | |||||
| <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm text-slate-400"> | |||||
| <p>© {{ new Date().getFullYear() }} 奇想宇宙 AIonline. All rights reserved.</p> | |||||
| </div> | |||||
| </footer> | |||||
| <!-- 登录弹窗 --> | |||||
| <LoginModal | |||||
| v-if="showLogin" | |||||
| @close="showLogin = false" | |||||
| @login-success="onLoginSuccess" | |||||
| /> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| const navItems = [ | |||||
| { label: '首页', to: '/' }, | |||||
| { label: 'AI 取名', to: '/ai-name' }, | |||||
| { label: '动漫头像', to: '/anime-avatar' }, | |||||
| { label: '充值', to: '/recharge' }, | |||||
| ] | |||||
| const { userInfo, isLogin, showLogin, setLogin } = useUser() | |||||
| function onLoginSuccess(data) { | |||||
| setLogin(data) | |||||
| showLogin.value = false | |||||
| } | |||||
| </script> | |||||
| @@ -0,0 +1,724 @@ | |||||
| <template> | |||||
| <div class="min-h-screen bg-[#f3f7fb]"> | |||||
| <!-- ========== Hero (对齐 V2 渐变风格) ========== --> | |||||
| <div class="relative overflow-hidden text-white" | |||||
| style="background: linear-gradient(135deg, #138cff 0%, #34c3ff 54%, #79e0df 100%)"> | |||||
| <div class="absolute w-[360px] h-[360px] rounded-full bg-white/20 -right-[110px] -top-[140px]" /> | |||||
| <div class="absolute w-[240px] h-[240px] rounded-full bg-white/15 right-[86px] -bottom-[130px]" /> | |||||
| <div class="relative max-w-7xl mx-auto px-4 sm:px-6 py-8 lg:py-10"> | |||||
| <div class="lg:flex lg:items-end lg:justify-between gap-8"> | |||||
| <div class="max-w-[460px]"> | |||||
| <div class="text-[11px] lg:text-xs font-bold tracking-wider text-white/75">AI NAME STUDIO</div> | |||||
| <h1 class="mt-3 text-[26px] lg:text-[36px] font-extrabold leading-tight">给宝宝取一个有出处的好名字</h1> | |||||
| <p class="mt-3 text-sm lg:text-base text-white/90 leading-relaxed">结合姓氏、性别、诗词典故和音律寓意,生成可直接挑选的名字方案。</p> | |||||
| </div> | |||||
| <div class="hidden lg:block w-[260px] flex-shrink-0"> | |||||
| <div class="rounded-3xl border border-white/40 bg-white/20 backdrop-blur p-5 shadow-[0_18px_34px_rgba(0,92,190,0.16)]"> | |||||
| <div class="text-3xl font-extrabold">{{ previewName }}</div> | |||||
| <div class="mt-1.5 text-xs text-white/85">李白《独坐敬亭山》</div> | |||||
| </div> | |||||
| <div class="mt-3 h-12 rounded-2xl border border-white/40 bg-white/20 backdrop-blur flex items-center justify-between px-4 text-xs text-white/90"> | |||||
| <span>音律</span><span>寓意</span><span>出处</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== Main Grid ========== --> | |||||
| <div class="max-w-7xl mx-auto px-4 sm:px-6 py-6 lg:py-8"> | |||||
| <div class="lg:grid lg:grid-cols-12 lg:gap-8"> | |||||
| <!-- ===== LEFT: Form (col-span-7) ===== --> | |||||
| <div class="lg:col-span-7 space-y-5 pb-24 lg:pb-0"> | |||||
| <!-- 1. 基础信息 Card --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_rgba(34,68,112,0.05)]"> | |||||
| <div class="flex items-baseline mb-4"> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">基础信息</h3> | |||||
| <span class="ml-auto text-xs text-[#8a95a6]">必填</span> | |||||
| </div> | |||||
| <!-- Surname + Gender (一行,对齐 V2 surname-box) --> | |||||
| <div class="flex items-stretch gap-3 rounded-2xl bg-[#f6f9fe] p-3.5"> | |||||
| <div class="flex-1 min-w-0"> | |||||
| <label class="block text-sm font-bold text-[#172033]">姓氏</label> | |||||
| <input v-model="form.surname" type="text" maxlength="5" placeholder="输入姓氏" | |||||
| class="w-full mt-1 bg-transparent text-xl lg:text-2xl font-extrabold text-[#172033] placeholder:text-[#a0aabb] focus:outline-none" /> | |||||
| </div> | |||||
| <div class="w-[180px] flex-shrink-0 p-1.5 rounded-[28px] bg-white shadow-[inset_0_0_0_1px_#edf2f7] flex"> | |||||
| <button v-for="opt in genderOptions" :key="opt.value" type="button" | |||||
| class="flex-1 h-10 rounded-[24px] text-sm font-bold transition-all" | |||||
| :class="form.sex === opt.value ? 'bg-[#0b8cff] text-white' : 'text-[#7f8896]'" | |||||
| @click="form.sex = opt.value">{{ opt.label }}</button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 出生信息开关 --> | |||||
| <div class="mt-4 rounded-2xl border-2 transition-colors" | |||||
| :class="birthInfoIgnored ? 'bg-[#f7f9fd] border-[#edf2f7]' : 'bg-[#eff8ff] border-[#0b8cff]/20'"> | |||||
| <div class="flex items-center p-4 cursor-pointer" @click="toggleBirthInfo"> | |||||
| <div class="flex-1 min-w-0"> | |||||
| <div class="text-sm font-extrabold text-[#172033]">参考出生信息</div> | |||||
| <div class="mt-1 text-xs text-[#7f8896]">{{ birthInfoIgnored ? '已关闭,生成时不参考农历和五行' : '开启后会结合农历和五行倾向' }}</div> | |||||
| </div> | |||||
| <div class="ml-3 w-[60px] h-7 p-1 rounded-full transition-colors flex-shrink-0" | |||||
| :class="!birthInfoIgnored ? 'bg-[#0b8cff]' : 'bg-[#d7deea]'"> | |||||
| <div class="w-5 h-5 rounded-full bg-white shadow transition-transform" | |||||
| :class="!birthInfoIgnored && 'translate-x-7'" /> | |||||
| </div> | |||||
| </div> | |||||
| <div v-if="!birthInfoIgnored" class="px-4 pb-4 space-y-3"> | |||||
| <div class="grid grid-cols-2 gap-3"> | |||||
| <div class="rounded-2xl bg-[#f6f9fe] p-4 flex items-start justify-between cursor-pointer min-h-[88px]" | |||||
| @click="openDatePicker"> | |||||
| <div class="min-w-0"> | |||||
| <div class="text-sm font-bold text-[#172033]">出生日期</div> | |||||
| <div class="mt-2 text-sm text-[#7f8896]">{{ birthDate || '请选择日期' }}</div> | |||||
| </div> | |||||
| <span class="ml-2 text-2xl text-[#a0aabb] leading-none flex-shrink-0">›</span> | |||||
| </div> | |||||
| <div class="rounded-2xl bg-[#f6f9fe] p-4 flex items-start justify-between cursor-pointer min-h-[88px]" | |||||
| :class="{ 'opacity-80': birthTimeUnknown }" @click="openTimePicker"> | |||||
| <div class="min-w-0"> | |||||
| <div class="text-sm font-bold text-[#172033]">出生时分</div> | |||||
| <div class="mt-2 text-sm text-[#7f8896]">{{ birthClockText }}</div> | |||||
| </div> | |||||
| <span class="ml-2 text-2xl text-[#a0aabb] leading-none flex-shrink-0">›</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 农历显示 --> | |||||
| <div v-if="birthLunarText" class="rounded-2xl bg-[#eff8ff] p-4 flex items-center"> | |||||
| <span class="mr-3 px-3 py-0.5 rounded-full bg-white text-[#0b8cff] text-xs font-extrabold flex-shrink-0">农历</span> | |||||
| <span class="text-sm font-bold text-[#3a5878] min-w-0">{{ birthLunarText }}</span> | |||||
| </div> | |||||
| <!-- 不知道具体时间 --> | |||||
| <div class="inline-flex items-center rounded-full px-4 py-2 cursor-pointer transition-colors border-2" | |||||
| :class="birthTimeUnknown ? 'bg-[#eff8ff] border-[#0b8cff] text-[#0b8cff]' : 'bg-[#f6f9fe] border-transparent text-[#7f8896]'" | |||||
| @click="toggleBirthTimeUnknown"> | |||||
| <span class="w-1.5 h-1.5 mr-2 rounded-full bg-current" /> | |||||
| <span class="text-xs font-bold">不知道具体时间</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 2. 名字出处 Card --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_rgba(34,68,112,0.05)]"> | |||||
| <div class="flex items-baseline"> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">名字出处</h3> | |||||
| <span class="ml-auto text-xs text-[#8a95a6]">选择偏好的文化来源</span> | |||||
| </div> | |||||
| <div class="mt-4 grid grid-cols-2 lg:grid-cols-3 gap-2.5"> | |||||
| <button v-for="src in visibleSourceOptions" :key="src.id" type="button" | |||||
| class="min-h-[72px] p-3 rounded-2xl border-2 transition-all text-left" | |||||
| :class="form.name_source === src.id | |||||
| ? 'bg-[#eff8ff] border-[#0b8cff] shadow-[0_8px_20px_rgba(11,140,255,0.1)]' | |||||
| : 'bg-[#f7f9fd] border-transparent hover:border-[#e7edf5]'" | |||||
| @click="form.name_source = src.id"> | |||||
| <div class="text-sm font-extrabold text-[#172033] text-center">{{ src.label }}</div> | |||||
| <div class="mt-1 text-[11px] text-[#8a95a6] text-center">{{ src.desc }}</div> | |||||
| </button> | |||||
| <button type="button" | |||||
| class="min-h-[72px] p-3 rounded-2xl border-2 bg-white border-[#e7edf5] hover:border-[#0b8cff]/30 transition-all text-left" | |||||
| @click="sourceExpanded = !sourceExpanded"> | |||||
| <div class="text-sm font-extrabold text-[#172033] text-center">{{ sourceExpanded ? '收起' : '更多' }}</div> | |||||
| <div class="mt-1 text-[11px] text-[#8a95a6] text-center">{{ sourceExpanded ? '精简显示' : '展开全部' }}</div> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 3. 名字偏好 Card --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_rgba(34,68,112,0.05)]"> | |||||
| <div class="flex items-baseline"> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">名字偏好</h3> | |||||
| <span class="ml-auto text-xs text-[#8a95a6]">用于控制生成结果的方向</span> | |||||
| </div> | |||||
| <!-- 字数 --> | |||||
| <div class="mt-5"> | |||||
| <div class="text-sm font-bold text-[#172033]">名字字数 <span class="text-xs font-normal text-[#8a95a6]">包含姓氏</span></div> | |||||
| <div class="mt-3 flex flex-wrap gap-2.5"> | |||||
| <button v-for="wc in wordOptions" :key="wc.id" type="button" | |||||
| class="h-10 px-5 rounded-full text-sm font-bold transition-all border-2" | |||||
| :class="form.words_num === wc.id ? 'bg-[#eff8ff] border-[#0b8cff] text-[#0b8cff]' : 'bg-[#f6f9fe] border-transparent text-[#596579]'" | |||||
| @click="form.words_num = wc.id">{{ wc.label }}</button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 是否叠词 --> | |||||
| <div class="mt-5"> | |||||
| <div class="text-sm font-bold text-[#172033]">是否叠词</div> | |||||
| <div class="mt-3 flex flex-wrap gap-2.5"> | |||||
| <button v-for="r in redupOptions" :key="r.id" type="button" | |||||
| class="h-10 px-5 rounded-full text-sm font-bold transition-all border-2" | |||||
| :class="form.is_redup === r.id ? 'bg-[#eff8ff] border-[#0b8cff] text-[#0b8cff]' : 'bg-[#f6f9fe] border-transparent text-[#596579]'" | |||||
| @click="form.is_redup = r.id">{{ r.label }}</button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 指定包含字 --> | |||||
| <div class="mt-5 flex items-center rounded-2xl bg-[#f7f9fd] p-4"> | |||||
| <div class="min-w-0"> | |||||
| <div class="text-sm font-bold text-[#172033]">指定包含字</div> | |||||
| <div class="mt-1 text-xs text-[#8a95a6]">可选,限制 1 个字</div> | |||||
| </div> | |||||
| <input v-model="form.special_word" type="text" maxlength="1" placeholder="如:辰" | |||||
| class="ml-auto w-24 h-12 rounded-2xl bg-white text-center text-lg font-extrabold text-[#172033] placeholder:text-[#a0aabb] focus:outline-none focus:ring-2 focus:ring-[#0b8cff]/30" /> | |||||
| </div> | |||||
| <!-- 补充描述 --> | |||||
| <div class="mt-4 rounded-2xl bg-[#f7f9fd] p-4"> | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <div> | |||||
| <div class="text-sm font-bold text-[#172033]">补充描述</div> | |||||
| <div class="mt-1 text-xs text-[#8a95a6]">额外要求、寓意和期望</div> | |||||
| </div> | |||||
| <div class="text-xs text-[#9aa5b5] flex-shrink-0">{{ (form.content || '').length }}/200</div> | |||||
| </div> | |||||
| <textarea v-model="form.content" rows="3" maxlength="200" placeholder="您可以补充您的额外要求,也可以添加您的寓意和期望。200字以内..." | |||||
| class="mt-3 w-full bg-transparent text-sm text-[#172033] placeholder:text-[#a0aabb] focus:outline-none resize-none leading-relaxed"></textarea> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 4. Promise Card --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_rgba(34,68,112,0.05)] space-y-4"> | |||||
| <div class="flex items-center"> | |||||
| <span class="w-7 h-5 mr-3 rounded-[14px] bg-[#eef6ff] text-[#0b8cff] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">01</span> | |||||
| <span class="text-sm text-[#596579] leading-relaxed">可关闭出生信息参考,关闭后不结合农历和五行</span> | |||||
| </div> | |||||
| <div class="flex items-center"> | |||||
| <span class="w-7 h-5 mr-3 rounded-[14px] bg-[#eef6ff] text-[#0b8cff] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">02</span> | |||||
| <span class="text-sm text-[#596579] leading-relaxed">开启后可选择日期和时分,不确定时辰可选"不知道具体时间"</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- Desktop Submit --> | |||||
| <div class="hidden lg:flex items-center gap-3 rounded-2xl bg-white p-4 shadow-[0_8px_28px_rgba(34,68,112,0.05)]"> | |||||
| <div class="flex-1 min-w-0"> | |||||
| <div class="text-sm font-bold text-[#172033] whitespace-nowrap">{{ balanceText }}</div> | |||||
| <div v-if="isBalanceInsufficient" class="mt-1 text-xs text-[#f04438]">点数不足,请前往充值</div> | |||||
| </div> | |||||
| <button type="button" | |||||
| class="px-10 h-14 rounded-full bg-[#0b8cff] hover:bg-[#0a7ce0] active:bg-[#096dca] text-white font-bold text-base shadow-[0_12px_24px_rgba(11,140,255,0.25)] transition-all flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-w-[160px]" | |||||
| :disabled="!canSubmit || submitting" @click="handleSubmit"> | |||||
| <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" /> | |||||
| <span>{{ submitting ? '生成中...' : submitText }}</span> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ===== RIGHT: Result (col-span-5, sticky) ===== --> | |||||
| <div class="lg:col-span-5"> | |||||
| <div class="lg:sticky lg:top-6 space-y-4"> | |||||
| <div v-if="!resultState.visible" class="bg-white rounded-2xl p-8 lg:p-10 shadow-sm text-center min-h-[300px] flex flex-col items-center justify-center"> | |||||
| <UIcon name="i-lucide-sparkles" class="size-12 text-[#a0aabb] mb-4" /> | |||||
| <div class="text-base font-medium text-[#172033] mb-1">等待生成</div> | |||||
| <div class="text-sm text-[#a0aabb]">填写左侧信息并点击"开始取名"</div> | |||||
| <div class="text-sm text-[#a0aabb]">AI将为您推荐最佳名字方案</div> | |||||
| </div> | |||||
| <div v-if="resultState.loading" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm"> | |||||
| <div class="text-base font-bold text-[#172033] mb-1">{{ questionTitle }}</div> | |||||
| <div class="text-sm text-[#7f8896] mb-6">AI 正在分析音律、寓意和出处...</div> | |||||
| <div class="rounded-2xl bg-[#f7f9fd] py-10 flex flex-col items-center"> | |||||
| <div class="flex gap-1.5 mb-4"> | |||||
| <span class="w-2.5 h-2.5 rounded-full bg-[#0b8cff] animate-pulse-dot" /> | |||||
| <span class="w-2.5 h-2.5 rounded-full bg-[#0b8cff] animate-pulse-dot2" /> | |||||
| <span class="w-2.5 h-2.5 rounded-full bg-[#0b8cff] animate-pulse-dot3" /> | |||||
| </div> | |||||
| <div class="text-sm font-bold text-[#172033]">正在整理名字方案</div> | |||||
| </div> | |||||
| </div> | |||||
| <div v-if="resultState.error" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10"> | |||||
| <UIcon name="i-lucide-circle-alert" class="size-12 text-red-400 mb-3 mx-auto" /> | |||||
| <div class="text-base font-medium text-[#f04438] mb-2">生成失败</div> | |||||
| <div class="text-sm text-[#7f8896] mb-4">{{ resultState.errorMsg }}</div> | |||||
| <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton> | |||||
| </div> | |||||
| <div v-if="resultState.empty" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10"> | |||||
| <UIcon name="i-lucide-search-x" class="size-12 text-[#a0aabb] mb-3 mx-auto" /> | |||||
| <div class="text-base font-bold text-[#172033] mb-1">本次没有可用名字</div> | |||||
| <div class="text-sm text-[#7f8896] max-w-xs mx-auto">{{ result.summary || 'AI 返回的候选名未通过姓名可用性筛选,请调整条件或重新生成。' }}</div> | |||||
| <UButton color="primary" size="sm" class="mt-5" @click="handleSubmit">重新生成</UButton> | |||||
| </div> | |||||
| <template v-if="resultState.success"> | |||||
| <div v-if="result.summary" class="bg-white rounded-2xl p-5 lg:p-6 shadow-sm"> | |||||
| <div class="text-sm text-[#596579] leading-relaxed">{{ result.summary }}</div> | |||||
| </div> | |||||
| <div v-for="(item, idx) in result.names" :key="idx" class="bg-white rounded-2xl p-5 lg:p-6 shadow-sm"> | |||||
| <div class="flex items-center justify-between mb-2"> | |||||
| <span class="text-2xl lg:text-3xl font-bold text-[#0b8cff]">{{ item.name }}</span> | |||||
| <span v-if="item.score" class="text-lg font-bold text-[#ff8a00]">{{ item.score }}分</span> | |||||
| </div> | |||||
| <div v-if="item.pinyin" class="text-xs text-[#7f8896] mb-3">{{ item.pinyin }}</div> | |||||
| <div class="space-y-2.5"> | |||||
| <div v-for="field in nameFields" :key="field.key"> | |||||
| <div v-if="item[field.key]" class="flex items-start gap-2.5"> | |||||
| <span class="flex-shrink-0 inline-flex items-center justify-center h-[26px] px-2 rounded-full bg-[#eef6ff] text-[#0b8cff] text-[11px] font-medium">{{ field.label }}</span> | |||||
| <span class="text-sm text-[#172033] leading-relaxed pt-0.5">{{ item[field.key] }}</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <div class="flex gap-3"> | |||||
| <UButton color="primary" variant="outline" size="lg" class="flex-1 !rounded-full" :loading="submitting" @click="handleSubmit">重新生成</UButton> | |||||
| <UButton color="primary" size="lg" class="flex-1 !rounded-full" @click="copyResult">复制结果</UButton> | |||||
| </div> | |||||
| </template> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== Mobile Bottom Bar (fixed) ========== --> | |||||
| <div class="lg:hidden fixed left-0 right-0 bottom-0 z-50 bg-white/95 backdrop-blur shadow-[0_-8px_24px_rgba(35,55,90,0.08)]" | |||||
| style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))"> | |||||
| <div class="flex items-center justify-between mb-2.5 gap-3 text-sm"> | |||||
| <div class="min-w-0 truncate text-[#7b8797]">{{ balanceText }}</div> | |||||
| <button v-if="isBalanceInsufficient" class="text-[#0b8cff] text-xs font-extrabold flex-shrink-0" @click="goRecharge">去充值</button> | |||||
| </div> | |||||
| <div class="flex gap-3"> | |||||
| <button class="w-[120px] h-12 rounded-full bg-[#eef6ff] text-[#0b8cff] text-sm font-bold" @click="goHistory">历史记录</button> | |||||
| <button class="flex-1 h-12 rounded-full text-white font-bold shadow-[0_12px_24px_rgba(11,140,255,0.2)] transition-opacity" | |||||
| :class="(canSubmit && !submitting) ? 'bg-[#0b8cff]' : 'bg-[#0b8cff] opacity-60'" | |||||
| :disabled="!canSubmit || submitting" @click="handleSubmit"> | |||||
| {{ submitting ? '生成中...' : submitText }} | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== Date Picker Modal (custom calendar grid) ========== --> | |||||
| <Teleport to="body"> | |||||
| <Transition name="modal"> | |||||
| <div v-if="dateOpen" class="fixed inset-0 z-[100] flex items-center justify-center p-4"> | |||||
| <div class="absolute inset-0 bg-black/50" @click="dateOpen = false" /> | |||||
| <div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"> | |||||
| <div class="flex items-center justify-between px-5 py-4 border-b border-gray-100"> | |||||
| <h3 class="text-base font-extrabold text-[#172033]">选择出生日期</h3> | |||||
| <button class="text-[#a0aabb] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100" @click="dateOpen = false">×</button> | |||||
| </div> | |||||
| <div class="p-5"> | |||||
| <div class="flex items-center justify-between mb-4"> | |||||
| <button type="button" class="w-9 h-9 rounded-full hover:bg-gray-100 flex items-center justify-center text-[#172033]" @click="prevMonth"> | |||||
| <UIcon name="i-lucide-chevron-left" class="size-5" /> | |||||
| </button> | |||||
| <div class="text-base font-extrabold text-[#172033]">{{ calendarYear }}年{{ calendarMonth }}月</div> | |||||
| <button type="button" class="w-9 h-9 rounded-full hover:bg-gray-100 flex items-center justify-center text-[#172033] disabled:opacity-30 disabled:cursor-not-allowed" :disabled="isNextMonthDisabled" @click="nextMonth"> | |||||
| <UIcon name="i-lucide-chevron-right" class="size-5" /> | |||||
| </button> | |||||
| </div> | |||||
| <div class="grid grid-cols-7 gap-1 text-center text-xs text-[#7f8896] mb-2"> | |||||
| <div v-for="dow in ['日', '一', '二', '三', '四', '五', '六']" :key="dow" class="h-8 flex items-center justify-center">{{ dow }}</div> | |||||
| </div> | |||||
| <div class="grid grid-cols-7 gap-1"> | |||||
| <button v-for="day in calendarDays" :key="day.key" type="button" | |||||
| class="aspect-square flex items-center justify-center text-sm rounded-full transition-colors relative" | |||||
| :class="{ | |||||
| 'text-[#cbd5e1]': day.otherMonth && !day.isSelected, | |||||
| 'text-[#172033] hover:bg-gray-100': !day.otherMonth && !day.isSelected && !day.isToday && !day.disabled, | |||||
| 'ring-1 ring-[#0b8cff] text-[#0b8cff] font-bold': day.isToday && !day.isSelected, | |||||
| 'bg-[#0b8cff] text-white font-bold': day.isSelected, | |||||
| 'opacity-30 cursor-not-allowed': day.disabled, | |||||
| }" | |||||
| :disabled="day.disabled" | |||||
| @click="selectDay(day)"> | |||||
| {{ day.day }} | |||||
| </button> | |||||
| </div> | |||||
| <div class="mt-4 text-center text-xs text-[#7f8896]">小贴士:可选过去日期,今日及之前可选</div> | |||||
| </div> | |||||
| <div class="flex gap-3 px-5 py-4 border-t border-gray-100"> | |||||
| <button type="button" class="flex-1 h-11 rounded-full border border-gray-200 text-[#596579] font-bold hover:bg-gray-50" @click="dateOpen = false">取消</button> | |||||
| <button type="button" class="flex-1 h-11 rounded-full bg-[#0b8cff] text-white font-bold hover:bg-[#0a7ce0]" @click="confirmDate">确定</button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </Transition> | |||||
| </Teleport> | |||||
| <!-- ========== Time Picker Modal (UInputTime) ========== --> | |||||
| <Teleport to="body"> | |||||
| <Transition name="modal"> | |||||
| <div v-if="timeOpen" class="fixed inset-0 z-[100] flex items-center justify-center p-4"> | |||||
| <div class="absolute inset-0 bg-black/50" @click="timeOpen = false" /> | |||||
| <div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"> | |||||
| <div class="flex items-center justify-between px-5 py-4 border-b border-gray-100"> | |||||
| <h3 class="text-base font-extrabold text-[#172033]">选择出生时分</h3> | |||||
| <button class="text-[#a0aabb] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100" @click="timeOpen = false">×</button> | |||||
| </div> | |||||
| <div class="p-6"> | |||||
| <UInputTime v-model="tempTimeValue" class="w-full" /> | |||||
| <div class="mt-4 text-center text-xs text-[#7f8896]">不确定时辰?点下方"不知道具体时间"</div> | |||||
| </div> | |||||
| <div class="flex gap-3 px-5 py-4 border-t border-gray-100"> | |||||
| <button type="button" class="flex-1 h-11 rounded-full border border-gray-200 text-[#0b8cff] font-bold hover:bg-gray-50" @click="timeUnknownPick">不知道具体时间</button> | |||||
| <button type="button" class="flex-1 h-11 rounded-full bg-[#0b8cff] text-white font-bold hover:bg-[#0a7ce0]" @click="confirmTime">确定</button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </Transition> | |||||
| </Teleport> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| import { ref, reactive, computed, watch, onMounted } from 'vue' | |||||
| import { Time } from '@internationalized/date' | |||||
| const _api = useApi() | |||||
| const api = _api // _api 是 { get, post, request },别名保持 api.post(...) 调用 | |||||
| const user = useUser() | |||||
| const router = useRouter() | |||||
| const toast = useToast() | |||||
| function showToast(message, color = 'red') { | |||||
| toast.add({ title: message, color }) | |||||
| } | |||||
| // ==================== Options (对齐 V2) ==================== | |||||
| const genderOptions = [ | |||||
| { label: '男孩', value: 1 }, | |||||
| { label: '女孩', value: 2 }, | |||||
| ] | |||||
| const sourceOptions = [ | |||||
| { id: '0', label: '不限', desc: '综合推荐' }, | |||||
| { id: '1', label: '诗经', desc: '温润雅致' }, | |||||
| { id: '2', label: '楚辞', desc: '浪漫开阔' }, | |||||
| { id: '3', label: '离骚', desc: '清朗有骨' }, | |||||
| { id: '4', label: '周易', desc: '稳重含蓄' }, | |||||
| { id: '5', label: '史记', desc: '大气有典' }, | |||||
| { id: '6', label: '诗词歌赋', desc: '文采舒展' }, | |||||
| { id: '7', label: '成语典故', desc: '寓意明确' }, | |||||
| { id: '8', label: '佛教', desc: '清净平和' }, | |||||
| { id: '9', label: '道教', desc: '自然洒脱' }, | |||||
| { id: '10', label: '自然景观', desc: '明亮灵动' }, | |||||
| ] | |||||
| const wordOptions = [ | |||||
| { id: '0', label: '不限' }, { id: '2', label: '2字' }, { id: '3', label: '3字' }, | |||||
| { id: '4', label: '4字' }, { id: '5', label: '5字' }, | |||||
| ] | |||||
| const redupOptions = [ | |||||
| { id: '0', label: '不限' }, { id: '1', label: '是' }, { id: '2', label: '否' }, | |||||
| ] | |||||
| const nameFields = [ | |||||
| { key: 'source', label: '出处' }, { key: 'wuxing', label: '五行' }, | |||||
| { key: 'meaning', label: '寓意' }, { key: 'tone', label: '音律' }, { key: 'reason', label: '推荐' }, | |||||
| ] | |||||
| // ==================== Form ==================== | |||||
| const form = reactive({ | |||||
| surname: '', sex: 0, name_source: '0', words_num: '0', | |||||
| is_redup: '0', special_word: '', content: '', | |||||
| birth_date: '', birth_clock: '', birth_time_unknown: 1, | |||||
| }) | |||||
| const sourceExpanded = ref(false) | |||||
| const visibleSourceOptions = computed(() => { | |||||
| if (sourceExpanded.value) return sourceOptions | |||||
| const selected = sourceOptions.find(i => i.id === form.name_source) | |||||
| const rows = [sourceOptions[0]] | |||||
| if (selected && selected.id !== '0') rows.push(selected) | |||||
| sourceOptions.slice(1).forEach(i => { if (rows.length < 5 && !rows.some(r => r.id === i.id)) rows.push(i) }) | |||||
| return rows | |||||
| }) | |||||
| // ==================== Birth Info ==================== | |||||
| const birthInfoIgnored = ref(false) | |||||
| const birthDate = ref('') | |||||
| const birthClock = ref('') | |||||
| const birthTimeUnknown = ref(true) | |||||
| const dateOpen = ref(false) | |||||
| const timeOpen = ref(false) | |||||
| const tempTimeValue = ref(new Time(8, 0)) | |||||
| // Custom calendar grid | |||||
| const todayDate = new Date() | |||||
| const calendarYear = ref(todayDate.getFullYear()) | |||||
| const calendarMonth = ref(todayDate.getMonth() + 1) | |||||
| const pendingDay = ref(null) // 用户在网格里点击但未确认的日期 | |||||
| const calendarDays = computed(() => { | |||||
| const year = calendarYear.value | |||||
| const month = calendarMonth.value | |||||
| const firstDayOfWeek = new Date(year, month - 1, 1).getDay() // 0=Sun | |||||
| const daysInMonth = new Date(year, month, 0).getDate() | |||||
| const daysInPrevMonth = new Date(year, month - 1, 0).getDate() | |||||
| const todayStr = formatYMD(todayDate) | |||||
| const currentPending = pendingDay.value | |||||
| const days = [] | |||||
| // 上月填充 | |||||
| for (let i = firstDayOfWeek - 1; i >= 0; i--) { | |||||
| const d = daysInPrevMonth - i | |||||
| const dateStr = formatYMD(year, month - 1, d) | |||||
| days.push({ key: `prev-${dateStr}`, day: d, otherMonth: true, date: dateStr, disabled: true, isToday: false, isSelected: false }) | |||||
| } | |||||
| // 当月 | |||||
| for (let d = 1; d <= daysInMonth; d++) { | |||||
| const dateStr = formatYMD(year, month, d) | |||||
| const isFuture = dateStr > todayStr | |||||
| const isSelected = !!currentPending && currentPending === dateStr | |||||
| days.push({ | |||||
| key: `cur-${dateStr}`, day: d, otherMonth: false, date: dateStr, | |||||
| disabled: isFuture, isToday: dateStr === todayStr, isSelected, | |||||
| }) | |||||
| } | |||||
| // 下月填充至 42 格 | |||||
| let nextDay = 1 | |||||
| while (days.length < 42) { | |||||
| const dateStr = formatYMD(year, month + 1, nextDay) | |||||
| days.push({ key: `next-${dateStr}`, day: nextDay, otherMonth: true, date: dateStr, disabled: true, isToday: false, isSelected: false }) | |||||
| nextDay++ | |||||
| } | |||||
| return days | |||||
| }) | |||||
| const isNextMonthDisabled = computed(() => { | |||||
| const today = new Date() | |||||
| return calendarYear.value > today.getFullYear() || | |||||
| (calendarYear.value === today.getFullYear() && calendarMonth.value >= today.getMonth() + 1) | |||||
| }) | |||||
| function formatYMD(year, month, day) { | |||||
| const m = month < 0 ? 12 : month > 12 ? 1 : month | |||||
| return `${year}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}` | |||||
| } | |||||
| function prevMonth() { | |||||
| if (calendarMonth.value === 1) { calendarMonth.value = 12; calendarYear.value-- } | |||||
| else calendarMonth.value-- | |||||
| } | |||||
| function nextMonth() { | |||||
| if (isNextMonthDisabled.value) return | |||||
| if (calendarMonth.value === 12) { calendarMonth.value = 1; calendarYear.value++ } | |||||
| else calendarMonth.value++ | |||||
| } | |||||
| function selectDay(day) { | |||||
| if (day.disabled) return | |||||
| pendingDay.value = day.date | |||||
| } | |||||
| function toggleBirthInfo() { | |||||
| birthInfoIgnored.value = !birthInfoIgnored.value | |||||
| syncBirthFields() | |||||
| } | |||||
| function toggleBirthTimeUnknown() { | |||||
| if (birthInfoIgnored.value) return | |||||
| birthTimeUnknown.value = !birthTimeUnknown.value | |||||
| if (birthTimeUnknown.value) birthClock.value = '' | |||||
| syncBirthFields() | |||||
| } | |||||
| function openDatePicker() { | |||||
| if (birthInfoIgnored.value) return | |||||
| if (birthDate.value) { | |||||
| const p = birthDate.value.split('-') | |||||
| calendarYear.value = +p[0] | |||||
| calendarMonth.value = +p[1] | |||||
| pendingDay.value = birthDate.value | |||||
| } else { | |||||
| calendarYear.value = todayDate.getFullYear() | |||||
| calendarMonth.value = todayDate.getMonth() + 1 | |||||
| pendingDay.value = null | |||||
| } | |||||
| dateOpen.value = true | |||||
| } | |||||
| function confirmDate() { | |||||
| if (pendingDay.value) { | |||||
| birthDate.value = pendingDay.value | |||||
| syncBirthFields() | |||||
| } | |||||
| dateOpen.value = false | |||||
| } | |||||
| function openTimePicker() { | |||||
| if (birthInfoIgnored.value) return | |||||
| const clock = birthClock.value || '08:00' | |||||
| const [h, m] = clock.split(':') | |||||
| tempTimeValue.value = new Time(+h, +m) | |||||
| timeOpen.value = true | |||||
| } | |||||
| function confirmTime() { | |||||
| const t = tempTimeValue.value | |||||
| if (t) { | |||||
| birthClock.value = `${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}` | |||||
| birthTimeUnknown.value = false | |||||
| syncBirthFields() | |||||
| } | |||||
| timeOpen.value = false | |||||
| } | |||||
| function timeUnknownPick() { | |||||
| birthTimeUnknown.value = true | |||||
| birthClock.value = '' | |||||
| syncBirthFields() | |||||
| timeOpen.value = false | |||||
| } | |||||
| function syncBirthFields() { | |||||
| if (birthInfoIgnored.value) { | |||||
| form.birth_date = ''; form.birth_clock = ''; form.birth_time_unknown = 1 | |||||
| return | |||||
| } | |||||
| form.birth_date = birthDate.value || '' | |||||
| form.birth_clock = birthTimeUnknown.value ? '' : (birthClock.value || '') | |||||
| form.birth_time_unknown = birthTimeUnknown.value ? 1 : 0 | |||||
| } | |||||
| const birthClockText = computed(() => { | |||||
| if (birthTimeUnknown.value) return '不知道具体时间' | |||||
| return birthClock.value || '请选择时分' | |||||
| }) | |||||
| const previewName = '白敬亭' | |||||
| // 农历 | |||||
| const birthLunarText = ref('') | |||||
| watch(birthDate, async (val) => { | |||||
| if (!val) { birthLunarText.value = ''; return } | |||||
| try { | |||||
| const { Solar } = await import('lunar-javascript') | |||||
| const p = val.split('-') | |||||
| const lunar = Solar.fromYmd(+p[0], +p[1], +p[2]).getLunar() | |||||
| birthLunarText.value = `农历${lunar.getYearInGanZhi()}年 ${lunar.getMonthInChinese()}${lunar.getDayInChinese()}` | |||||
| } catch { birthLunarText.value = '' } | |||||
| }, { immediate: true }) | |||||
| // ==================== Billing & Submit ==================== | |||||
| const billing = reactive({ loaded: false, points: 0 }) | |||||
| const balance = reactive({ points: 0 }) | |||||
| async function loadBilling() { | |||||
| try { | |||||
| const res = await api.post('/billing/config', { app_key: 'ai-name' }) | |||||
| if (res.code === 0 && res.data) { | |||||
| billing.points = res.data.quote?.points || res.data.points || 0 | |||||
| if (res.data.balance) balance.points = res.data.balance.points || res.data.balance.point_balance || 0 | |||||
| } | |||||
| } catch {} finally { billing.loaded = true } | |||||
| } | |||||
| async function loadBalance() { | |||||
| try { | |||||
| const res = await api.post('/user/getbalance', {}) | |||||
| if (res.code === 0 && res.data) balance.points = res.data.points || res.data.point_balance || 0 | |||||
| } catch {} | |||||
| } | |||||
| onMounted(() => { loadBilling(); if (user.isLogin.value) loadBalance() }) | |||||
| const submitting = ref(false) | |||||
| const questionTitle = ref('') | |||||
| const canSubmit = computed(() => !!form.surname && !submitting.value) | |||||
| const balanceText = computed(() => { | |||||
| const cost = Number(billing.points || 0) | |||||
| if (!user.isLogin.value) return cost ? `AI取名 · ${cost}点/次` : 'AI取名' | |||||
| return cost ? `余额${balance.points}点 · 预计消耗${cost}点` : `余额${balance.points}点` | |||||
| }) | |||||
| const submitText = computed(() => { | |||||
| const cost = Number(billing.points || 0) | |||||
| if (!user.isLogin.value) return cost ? `登录后取名 · ${cost}点` : '登录后取名' | |||||
| return cost ? `立即取名 · ${cost}点` : '立即取名' | |||||
| }) | |||||
| const isBalanceInsufficient = computed(() => { | |||||
| const cost = Number(billing.points || 0) | |||||
| return user.isLogin.value && cost > 0 && balance.points >= 0 && balance.points < cost | |||||
| }) | |||||
| const resultState = ref({ visible: false, loading: false, error: false, empty: false, success: false, errorMsg: '' }) | |||||
| const result = ref({ summary: '', names: [] }) | |||||
| async function handleSubmit() { | |||||
| if (!user.isLogin.value) { showToast('请先登录'); return router.push('/login') } | |||||
| if (!form.surname) { showToast('请输入姓氏'); return } | |||||
| if (submitting.value) return | |||||
| syncBirthFields() | |||||
| submitting.value = true | |||||
| resultState.value = { visible: true, loading: true, error: false, empty: false, success: false, errorMsg: '' } | |||||
| result.value = { summary: '', names: [] } | |||||
| questionTitle.value = `${form.surname}${form.sex === 2 ? '女孩' : form.sex === 1 ? '男孩' : ''}好名字` | |||||
| const params = { | |||||
| surname: form.surname, sex: form.sex, name_source: form.name_source, | |||||
| words_num: form.words_num, is_redup: form.is_redup, special_word: form.special_word, | |||||
| content: form.content, model: 4, | |||||
| birth_date: form.birth_date, birth_clock: form.birth_clock, birth_time_unknown: form.birth_time_unknown, | |||||
| } | |||||
| try { | |||||
| const res = await api.post('/ainame/send', params) | |||||
| if (res.code === 1011) { | |||||
| resultState.value = { ...resultState.value, loading: false, error: true, errorMsg: '点数不足,请充值后重试' } | |||||
| return | |||||
| } | |||||
| if (res.code !== 0) { | |||||
| resultState.value = { ...resultState.value, loading: false, error: true, errorMsg: res.msg || '生成失败' } | |||||
| return | |||||
| } | |||||
| const data = res.data || {} | |||||
| const parsed = parseResult(data.content || data) | |||||
| if (parsed.names && parsed.names.length > 0) { | |||||
| result.value = parsed | |||||
| resultState.value = { visible: true, loading: false, error: false, empty: false, success: true, errorMsg: '' } | |||||
| } else { | |||||
| result.value = parsed | |||||
| resultState.value = { visible: true, loading: false, error: false, empty: true, success: false, errorMsg: '' } | |||||
| } | |||||
| } catch (e) { | |||||
| resultState.value = { ...resultState.value, loading: false, error: true, errorMsg: e.msg || '网络异常,请重试' } | |||||
| } finally { submitting.value = false } | |||||
| } | |||||
| function parseResult(content) { | |||||
| if (!content) return { summary: '', names: [] } | |||||
| let text = String(content).trim().replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim() | |||||
| const line = parseJsonLines(text); if (line.names.length) return line | |||||
| const marked = parseMarkedText(text); if (marked.names.length) return marked | |||||
| const s = text.indexOf('{'), e = text.lastIndexOf('}') | |||||
| if (s >= 0 && e > s) text = text.substring(s, e + 1) | |||||
| try { const d = JSON.parse(text); return { summary: d.summary || '', names: Array.isArray(d.names) ? d.names : [] } } | |||||
| catch { return { summary: text, names: [] } } | |||||
| } | |||||
| function parseJsonLines(text) { | |||||
| const d = { summary: '', names: [] } | |||||
| String(text || '').split(/\r?\n/).forEach(l => { l = l.trim(); if (!l) return; try { const i = JSON.parse(l); if (i.type === 'summary') d.summary = i.content || ''; if (i.type === 'name' && i.data) d.names.push(i.data) } catch {} }) | |||||
| return d | |||||
| } | |||||
| function parseMarkedText(text) { | |||||
| const d = { summary: '', names: [] }; const v = String(text || '') | |||||
| const sm = v.match(/整体建议[::]([\s\S]*?)(?=\n\s*【|$)/); if (sm) d.summary = sm[1].trim() | |||||
| const reg = /【([^】]+)】([\s\S]*?)(?=\n\s*【|$)/g; let m | |||||
| while ((m = reg.exec(v))) { | |||||
| const body = m[2] || '', name = resolveNameBlockTitle(m[1], body) | |||||
| d.names.push({ name, pinyin: mf(body, '拼音'), score: parseInt(mf(body, '评分')) || 90, source: mf(body, '出处'), wuxing: mf(body, '五行'), meaning: mf(body, '寓意'), tone: mf(body, '音律'), reason: mf(body, '推荐') }) | |||||
| } | |||||
| d.names = d.names.filter(i => i.name).slice(0, 5); return d | |||||
| } | |||||
| function resolveNameBlockTitle(title, body) { | |||||
| const n = String(title || '').trim(); if (!['姓名', '名字', '候选名'].includes(n)) return n | |||||
| const fr = /^(拼音|评分|出处|五行|寓意|音律|推荐)[::]/; const line = String(body || '').split(/\r?\n/).map(i => i.trim()).find(i => i && !fr.test(i)) | |||||
| return line ? line.replace(/^姓名[::]/, '').trim() : n | |||||
| } | |||||
| function mf(body, label) { const r = new RegExp(`${label}[::]([^\\n\\r]*)`); const m = String(body || '').match(r); return m ? m[1].trim() : '' } | |||||
| function copyResult() { | |||||
| const names = result.value.names || [] | |||||
| if (!names.length) { showToast('暂无可复制内容'); return } | |||||
| const lines = [] | |||||
| if (result.value.summary) lines.push(result.value.summary) | |||||
| names.forEach(item => { | |||||
| lines.push(`${item.name}${item.score ? `(${item.score}分)` : ''}`) | |||||
| if (item.pinyin) lines.push(`拼音:${item.pinyin}`) | |||||
| if (item.source) lines.push(`出处:${item.source}`) | |||||
| if (item.wuxing) lines.push(`五行:${item.wuxing}`) | |||||
| if (item.meaning) lines.push(`寓意:${item.meaning}`) | |||||
| if (item.tone) lines.push(`音律:${item.tone}`) | |||||
| if (item.reason) lines.push(`推荐:${item.reason}`) | |||||
| lines.push('') | |||||
| }) | |||||
| navigator.clipboard.writeText(lines.join('\n')).then(() => showToast('复制成功', 'primary')).catch(() => showToast('复制失败')) | |||||
| } | |||||
| function goRecharge() { router.push('/recharge') } | |||||
| function goHistory() { router.push('/ai-name-history') } | |||||
| </script> | |||||
| <style scoped> | |||||
| .animate-pulse-dot { animation: dotPulse 1.1s ease-in-out infinite; } | |||||
| .animate-pulse-dot2 { animation: dotPulse 1.1s ease-in-out 0.15s infinite; } | |||||
| .animate-pulse-dot3 { animation: dotPulse 1.1s ease-in-out 0.3s infinite; } | |||||
| @keyframes dotPulse { | |||||
| 0%, 100% { opacity: 0.35; transform: scale(0.82); } | |||||
| 50% { opacity: 1; transform: scale(1); } | |||||
| } | |||||
| .modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; } | |||||
| .modal-enter-from, .modal-leave-to { opacity: 0; } | |||||
| .modal-enter-active > div:last-child, .modal-leave-active > div:last-child { transition: transform 0.2s ease; } | |||||
| .modal-enter-from > div:last-child, .modal-leave-to > div:last-child { transform: scale(0.95); } | |||||
| </style> | |||||
| @@ -0,0 +1,644 @@ | |||||
| <template> | |||||
| <div class="min-h-screen bg-[#f3f7fb]"> | |||||
| <!-- ========== Hero (对齐 V2 渐变风格) ========== --> | |||||
| <div class="relative overflow-hidden" | |||||
| style="background: linear-gradient(135deg, #ffffff 0%, #edf6ff 58%, #fff7f0 100%)"> | |||||
| <div class="max-w-7xl mx-auto px-4 sm:px-6 py-8 lg:py-10"> | |||||
| <div class="lg:flex lg:items-center lg:justify-between gap-8"> | |||||
| <div class="max-w-[480px]"> | |||||
| <div class="text-[11px] lg:text-xs font-extrabold tracking-wider text-[#1f8cff]">ANIME AVATAR STUDIO</div> | |||||
| <h1 class="mt-3 text-[26px] lg:text-[36px] font-extrabold leading-tight text-[#172033]">上传自拍,生成你的动漫头像</h1> | |||||
| <p class="mt-3 text-sm lg:text-base text-[#66758a] leading-relaxed">用真实对比看效果。支持竖图、半身照和普通自拍,脸部清晰会更稳定。</p> | |||||
| <div class="mt-5 flex items-center gap-3"> | |||||
| <button type="button" | |||||
| class="h-10 lg:h-11 px-5 rounded-full text-white text-sm font-extrabold bg-[#1f8cff] shadow-[0_10px_20px_rgba(31,140,255,0.22)] hover:bg-[#1979e6] transition-colors" | |||||
| @click="openCompare">查看对比</button> | |||||
| <button type="button" | |||||
| class="h-10 lg:h-11 px-5 rounded-full text-[#1f8cff] text-sm font-extrabold bg-white hover:bg-[#f7faff] transition-colors" | |||||
| @click="triggerUpload">上传照片</button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 预览对比卡片 (所有屏幕可见) --> | |||||
| <div v-if="demoPair.original && demoPair.anime" class="mt-6 lg:mt-0 lg:w-[200px] flex-shrink-0 cursor-pointer" @click="openCompare"> | |||||
| <div class="relative w-[140px] h-[210px] lg:w-[180px] lg:h-[270px] mx-auto rounded-[28px] overflow-hidden shadow-[0_18px_38px_rgba(31,62,112,0.18)] bg-[#dfe8f4]"> | |||||
| <img :src="demoPair.anime" class="w-full h-full object-cover" alt="anime" /> | |||||
| <div class="absolute left-0 top-0 bottom-0 w-1/2 overflow-hidden"> | |||||
| <img :src="demoPair.original" class="w-[140px] lg:w-[180px] h-full object-cover" alt="original" /> | |||||
| </div> | |||||
| <div class="absolute top-0 bottom-0 left-1/2 w-0.5 -ml-px bg-white/95 shadow-[0_0_12px_rgba(20,37,66,0.22)]" /> | |||||
| <div class="absolute top-3 left-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[#0f172a]/50">原图</div> | |||||
| <div class="absolute top-3 right-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[#0f172a]/50">动漫</div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== Main Grid ========== --> | |||||
| <div class="max-w-7xl mx-auto px-4 sm:px-6 py-6 lg:py-8"> | |||||
| <div class="lg:grid lg:grid-cols-12 lg:gap-8"> | |||||
| <!-- ===== LEFT: Form (col-span-7) ===== --> | |||||
| <div class="lg:col-span-7 space-y-5 pb-24 lg:pb-0"> | |||||
| <!-- 1. 参考照片 Upload Card --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]"> | |||||
| <div class="flex items-start justify-between gap-4 mb-4"> | |||||
| <div class="min-w-0"> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">参考照片</h3> | |||||
| <p class="mt-1 text-xs text-[#8a97a8]">建议使用正面、上半身、脸部无遮挡的照片</p> | |||||
| </div> | |||||
| <span class="flex-shrink-0 px-3 py-1 rounded-full text-[11px] font-bold text-[#ff6b6b] bg-[#fff1f1]">必选</span> | |||||
| </div> | |||||
| <!-- Upload Strip --> | |||||
| <div class="rounded-2xl border-2 border-[#e5edf7] bg-[#f7faff] p-4 flex items-center gap-4 cursor-pointer hover:border-[#1f8cff]/40 transition-colors" | |||||
| @click="triggerUpload" @dragover.prevent @drop.prevent="onDrop"> | |||||
| <div class="w-20 h-28 rounded-2xl bg-[#edf3fb] overflow-hidden relative flex-shrink-0"> | |||||
| <img v-if="previewImage" :src="previewImage" class="w-full h-full object-cover" alt="preview" /> | |||||
| <div v-else class="w-full h-full flex items-center justify-center"> | |||||
| <span class="w-9 h-9 rounded-full bg-[#1f8cff] text-white text-2xl font-light flex items-center justify-center">+</span> | |||||
| </div> | |||||
| <div v-if="uploading" class="absolute inset-0 bg-[#111e34]/60 text-white text-xs flex items-center justify-center">上传中</div> | |||||
| </div> | |||||
| <div class="flex-1 min-w-0"> | |||||
| <div class="text-sm lg:text-base font-extrabold text-[#172033]">{{ previewImage ? '已选择照片' : '选择一张清晰自拍' }}</div> | |||||
| <div class="mt-1.5 text-xs text-[#8a97a8]">不用裁成正方形,竖图和半身照也可以</div> | |||||
| </div> | |||||
| <div class="w-16 h-9 rounded-full bg-[#1f8cff] text-white text-xs font-extrabold flex items-center justify-center flex-shrink-0">{{ previewImage ? '更换' : '上传' }}</div> | |||||
| </div> | |||||
| <input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileChange" /> | |||||
| <!-- Tips --> | |||||
| <div class="mt-3 flex gap-2.5"> | |||||
| <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[#526174] bg-[#f1f5fa]">正面</span> | |||||
| <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[#526174] bg-[#f1f5fa]">脸部清晰</span> | |||||
| <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[#526174] bg-[#f1f5fa]">避免遮挡</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 2. 头像风格 --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]"> | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">头像风格</h3> | |||||
| <span class="text-xs text-[#8a97a8] mt-1">点选切换</span> | |||||
| </div> | |||||
| <div v-if="styleLoading" class="mt-4 flex justify-center py-8"> | |||||
| <span class="w-6 h-6 border-2 border-[#1f8cff] border-t-transparent rounded-full animate-spin" /> | |||||
| </div> | |||||
| <template v-else> | |||||
| <!-- 风格网格:移动 3 列 / 桌面 4 列,默认 2 行折叠 --> | |||||
| <div class="mt-4 grid grid-cols-3 lg:grid-cols-4 gap-2.5 lg:gap-3"> | |||||
| <button v-for="s in visibleStyleList" :key="s.value" type="button" | |||||
| class="p-2 rounded-2xl border-2 transition-all text-center" | |||||
| :class="form.img2img_style === String(s.value) | |||||
| ? 'bg-[#eef7ff] border-[#1f8cff]' | |||||
| : 'bg-[#f6f9fd] border-transparent hover:border-[#e5edf7]'" | |||||
| @click="selectStyle(s)"> | |||||
| <div class="w-full aspect-square rounded-xl overflow-hidden bg-[#eaf0f8] flex items-center justify-center"> | |||||
| <img v-if="s.pic" :src="s.pic" class="w-full h-full object-cover" :alt="s.name" /> | |||||
| <span v-else class="text-xs text-[#77869a]">{{ s.name }}</span> | |||||
| </div> | |||||
| <div class="mt-2 text-xs font-bold text-[#334155] truncate">{{ s.name }}</div> | |||||
| </button> | |||||
| </div> | |||||
| <!-- 展开/收起按钮 --> | |||||
| <button v-if="hasMoreStyles" type="button" | |||||
| class="mt-3 w-full h-10 rounded-xl border border-[#e5edf7] text-[#1f8cff] text-sm font-bold hover:bg-[#eef7ff] transition-colors flex items-center justify-center gap-1" | |||||
| @click="styleExpanded = !styleExpanded"> | |||||
| <span>{{ styleExpanded ? '收起' : '展开全部' }}</span> | |||||
| <UIcon :name="styleExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="size-4" /> | |||||
| </button> | |||||
| </template> | |||||
| </div> | |||||
| <!-- 3. 自由度 --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]"> | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <div> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">自由度</h3> | |||||
| <p class="mt-1 text-xs text-[#8a97a8]">{{ strengthCopy }}</p> | |||||
| </div> | |||||
| <span class="text-lg font-extrabold text-[#1f8cff]">{{ form.img2img_strength.toFixed(2) }}</span> | |||||
| </div> | |||||
| <input type="range" min="0.1" max="1" step="0.01" v-model.number="form.img2img_strength" | |||||
| class="mt-4 w-full h-2 rounded-full appearance-none bg-[#e7edf5] accent-[#1f8cff] cursor-pointer" /> | |||||
| <div class="mt-2 flex justify-between text-xs text-[#8a97a8]"> | |||||
| <span>更像原图</span><span>更有创意</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 4. 画面偏好 --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]"> | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">画面偏好</h3> | |||||
| <span class="text-xs text-[#8a97a8] mt-1">可选</span> | |||||
| </div> | |||||
| <div class="mt-4"> | |||||
| <label class="block text-xs font-bold text-[#526174] mb-2">描述词</label> | |||||
| <input v-model="form.content" type="text" maxlength="100" placeholder="例如:浅色背景、清爽、微笑" | |||||
| class="w-full h-12 rounded-xl bg-[#f6f9fd] px-4 text-sm text-[#172033] placeholder:text-[#a0aabb] focus:outline-none focus:ring-2 focus:ring-[#1f8cff]/30" /> | |||||
| </div> | |||||
| <div class="mt-4"> | |||||
| <label class="block text-xs font-bold text-[#526174] mb-2">反面提示词</label> | |||||
| <input v-model="form.no" type="text" maxlength="100" placeholder="例如:模糊、变形、低清晰度" | |||||
| class="w-full h-12 rounded-xl bg-[#f6f9fd] px-4 text-sm text-[#172033] placeholder:text-[#a0aabb] focus:outline-none focus:ring-2 focus:ring-[#1f8cff]/30" /> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 5. Tips --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)] space-y-3"> | |||||
| <div class="flex items-center"> | |||||
| <span class="w-7 h-5 mr-3 rounded-[14px] bg-[#eef7ff] text-[#1f8cff] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">01</span> | |||||
| <span class="text-sm text-[#5f6f83] leading-relaxed">上传照片后可直接生成,处理完成会进入作品详情页。</span> | |||||
| </div> | |||||
| <div class="flex items-center"> | |||||
| <span class="w-7 h-5 mr-3 rounded-[14px] bg-[#eef7ff] text-[#1f8cff] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">02</span> | |||||
| <span class="text-sm text-[#5f6f83] leading-relaxed">历史记录里可以查看生成进度,也可以继续打开已完成作品。</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- Desktop Submit --> | |||||
| <div class="hidden lg:flex items-center gap-3 rounded-2xl bg-white p-4 shadow-[0_10px_28px_rgba(32,56,92,0.05)]"> | |||||
| <div class="flex-1 min-w-0"> | |||||
| <div class="text-sm font-bold text-[#172033] whitespace-nowrap">{{ balanceText }}</div> | |||||
| <div v-if="isBalanceInsufficient" class="mt-1 text-xs text-[#f04438]">点数不足,请前往充值</div> | |||||
| </div> | |||||
| <button type="button" | |||||
| class="px-10 h-14 rounded-full text-white font-bold text-base shadow-[0_12px_24px_rgba(31,140,255,0.28)] transition-all flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-w-[160px]" | |||||
| :class="submitting ? 'bg-[#1979e6]' : 'bg-gradient-to-r from-[#1f8cff] to-[#41b9ff] hover:opacity-90'" | |||||
| :disabled="!canSubmit || submitting" @click="handleSubmit"> | |||||
| <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" /> | |||||
| <span>{{ submitting ? '生成中...' : submitText }}</span> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ===== RIGHT: Result (col-span-5, sticky) ===== --> | |||||
| <div class="lg:col-span-5"> | |||||
| <div class="lg:sticky lg:top-6 space-y-4"> | |||||
| <!-- Empty --> | |||||
| <div v-if="!resultState.visible" class="bg-white rounded-2xl p-8 lg:p-10 shadow-sm text-center min-h-[300px] flex flex-col items-center justify-center"> | |||||
| <UIcon name="i-lucide-image" class="size-12 text-[#a0aabb] mb-4" /> | |||||
| <div class="text-base font-medium text-[#172033] mb-1">等待生成</div> | |||||
| <div class="text-sm text-[#a0aabb]">上传照片并选择风格后</div> | |||||
| <div class="text-sm text-[#a0aabb]">点击"立即生成"即可看到效果</div> | |||||
| </div> | |||||
| <!-- Loading --> | |||||
| <div v-if="resultState.loading" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm"> | |||||
| <div class="text-base font-bold text-[#172033] mb-1">AI 正在创作中</div> | |||||
| <div class="text-sm text-[#7f8896] mb-6">预计需要 10-30 秒,请耐心等待...</div> | |||||
| <div class="rounded-2xl bg-[#f7f9fd] py-10 flex flex-col items-center"> | |||||
| <div class="flex gap-1.5 mb-4"> | |||||
| <span class="w-2.5 h-2.5 rounded-full bg-[#1f8cff] animate-pulse-dot" /> | |||||
| <span class="w-2.5 h-2.5 rounded-full bg-[#1f8cff] animate-pulse-dot2" /> | |||||
| <span class="w-2.5 h-2.5 rounded-full bg-[#1f8cff] animate-pulse-dot3" /> | |||||
| </div> | |||||
| <div class="text-sm font-bold text-[#172033]">正在生成你的动漫头像</div> | |||||
| <div v-if="pollCountdown > 0" class="mt-2 text-xs text-[#8a95a6]">下次刷新 {{ pollCountdown }}s</div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- Error --> | |||||
| <div v-if="resultState.error" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10"> | |||||
| <UIcon name="i-lucide-circle-alert" class="size-12 text-red-400 mb-3 mx-auto" /> | |||||
| <div class="text-base font-medium text-[#f04438] mb-2">生成失败</div> | |||||
| <div class="text-sm text-[#7f8896] mb-4">{{ resultState.errorMsg }}</div> | |||||
| <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton> | |||||
| </div> | |||||
| <!-- Success --> | |||||
| <div v-if="resultState.success" class="space-y-4"> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-sm"> | |||||
| <div class="text-base font-bold text-[#172033] mb-4">效果对比</div> | |||||
| <div class="grid grid-cols-2 gap-3"> | |||||
| <div> | |||||
| <div class="text-xs text-[#8a95a6] mb-2">原图</div> | |||||
| <img v-if="previewImage" :src="previewImage" class="w-full aspect-square rounded-xl object-cover bg-[#f7f9fd]" alt="original" /> | |||||
| </div> | |||||
| <div> | |||||
| <div class="text-xs text-[#8a95a6] mb-2">动漫头像</div> | |||||
| <img :src="genResultUrl" class="w-full aspect-square rounded-xl object-cover border-2 border-[#1f8cff] bg-[#f7f9fd]" alt="result" /> | |||||
| </div> | |||||
| </div> | |||||
| <div class="mt-5 flex gap-3"> | |||||
| <button type="button" | |||||
| class="flex-1 h-12 rounded-full border-2 border-[#1f8cff] text-[#1f8cff] font-bold hover:bg-[#eef7ff] transition-colors flex items-center justify-center" | |||||
| @click="openResultCompare"> | |||||
| <UIcon name="i-lucide-split-square-horizontal" class="size-4 mr-1.5" /> | |||||
| 对比查看 | |||||
| </button> | |||||
| <button type="button" | |||||
| class="flex-1 h-12 rounded-full bg-[#1f8cff] hover:bg-[#1979e6] text-white font-bold text-base shadow-[0_8px_20px_rgba(31,140,255,0.2)] transition-colors flex items-center justify-center" | |||||
| @click="downloadResult"> | |||||
| <UIcon name="i-lucide-download" class="size-4 mr-1.5" /> | |||||
| 下载头像 | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <div class="flex gap-3"> | |||||
| <button type="button" | |||||
| class="flex-1 h-12 rounded-full border-2 border-[#1f8cff] text-[#1f8cff] font-bold hover:bg-[#eef7ff] transition-colors" | |||||
| :disabled="submitting" @click="handleSubmit">重新生成</button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== Mobile Bottom Bar ========== --> | |||||
| <div class="lg:hidden fixed left-0 right-0 bottom-0 z-50 bg-white/95 backdrop-blur shadow-[0_-10px_30px_rgba(32,56,92,0.1)]" | |||||
| style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))"> | |||||
| <div class="flex items-center justify-between mb-2.5 gap-3 text-sm"> | |||||
| <div class="min-w-0 truncate text-[#7b8797]">{{ balanceText }}</div> | |||||
| <button v-if="isBalanceInsufficient" class="text-[#1f8cff] text-xs font-extrabold flex-shrink-0" @click="goRecharge">去充值</button> | |||||
| </div> | |||||
| <div class="flex gap-3"> | |||||
| <button class="w-[120px] h-12 rounded-full bg-[#eef7ff] text-[#1f8cff] text-sm font-extrabold" @click="goHistory">历史记录</button> | |||||
| <button class="flex-1 h-12 rounded-full text-white text-sm font-extrabold shadow-[0_12px_24px_rgba(31,140,255,0.28)] transition-opacity flex items-center justify-center" | |||||
| :class="(canSubmit && !submitting) ? 'bg-gradient-to-r from-[#1f8cff] to-[#41b9ff]' : 'bg-gradient-to-r from-[#1f8cff] to-[#41b9ff] opacity-60'" | |||||
| :disabled="!canSubmit || submitting" @click="handleSubmit"> | |||||
| <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" /> | |||||
| {{ submitting ? '生成中...' : submitText }} | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== Compare Modal (大图滑块对比,支持 demo 和生成结果) ========== --> | |||||
| <Teleport to="body"> | |||||
| <Transition name="modal"> | |||||
| <div v-if="compareVisible" class="fixed inset-0 z-[100] flex items-center justify-center p-4"> | |||||
| <div class="absolute inset-0 bg-[#0f172a]/60" @click="closeCompare" /> | |||||
| <div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden"> | |||||
| <div class="flex items-center justify-between px-5 py-4 border-b border-gray-100"> | |||||
| <h3 class="text-base font-extrabold text-[#172033]">{{ compareTarget === 'result' ? '生成结果对比' : '效果对比' }}</h3> | |||||
| <button class="text-[#a0aabb] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100" @click="closeCompare">×</button> | |||||
| </div> | |||||
| <div class="p-5"> | |||||
| <div ref="largeCompareRef" class="relative w-full aspect-[3/4] max-h-[70vh] rounded-2xl overflow-hidden bg-[#dfe8f4] cursor-ew-resize select-none" | |||||
| @mousedown="onCompareStart" @touchstart.passive="onCompareStart"> | |||||
| <!-- 动漫图(底层,全尺寸) --> | |||||
| <img v-if="comparePair.anime" :src="comparePair.anime" class="absolute inset-0 w-full h-full object-cover pointer-events-none" alt="anime" /> | |||||
| <!-- 原图(上层,用 clip-path 裁剪,与底层完全重叠) --> | |||||
| <img v-if="comparePair.original" :src="comparePair.original" class="absolute inset-0 w-full h-full object-cover pointer-events-none" | |||||
| :style="{ clipPath: `inset(0 ${100 - comparePercent}% 0 0)` }" alt="original" /> | |||||
| <!-- 分割线 + 拖拽手柄 --> | |||||
| <div class="absolute top-0 bottom-0 w-1 -ml-px bg-white/95 shadow-[0_0_12px_rgba(20,37,66,0.22)] pointer-events-none" :style="{ left: comparePercent + '%' }"> | |||||
| <div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-14 rounded-full bg-white shadow-[0_8px_18px_rgba(20,37,66,0.22)] flex items-center justify-center text-[#1f8cff] text-lg font-extrabold gap-px"> | |||||
| <span>‹</span><span>›</span> | |||||
| </div> | |||||
| </div> | |||||
| <div class="absolute top-3 left-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[#0f172a]/50 pointer-events-none">原图</div> | |||||
| <div class="absolute top-3 right-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[#0f172a]/50 pointer-events-none">{{ compareTarget === 'result' ? '生成图' : '动漫图' }}</div> | |||||
| </div> | |||||
| <div class="mt-3 text-center text-xs text-[#64748b]">左右拖动中间按钮查看变化</div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </Transition> | |||||
| </Teleport> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| import { computed, reactive, ref, onMounted, onUnmounted } from 'vue' | |||||
| const _api = useApi() | |||||
| const api = _api // _api 是 { get, post, request },这里直接别名保持调用方式不变 | |||||
| const user = useUser() | |||||
| const router = useRouter() | |||||
| const { uploadImage } = useUpload() | |||||
| const toast = useToast() | |||||
| function showToast(message, color = 'red') { | |||||
| toast.add({ title: message, color }) | |||||
| } | |||||
| const ANIME_BILLING_MODEL = 'tencent_anime' | |||||
| // ==================== Form ==================== | |||||
| const form = reactive({ | |||||
| imgs: [], content: '', ai_type: 3, style_type: 1, ar: '1:1', no: '', | |||||
| model: ANIME_BILLING_MODEL, model_type: 6, light: '', text: '', speed: '', | |||||
| img2img_style: '201', age: 80, change_sex_type: '0', img2img_strength: 0.6, num: 1, | |||||
| app_name: 'anime-avatar', | |||||
| }) | |||||
| // ==================== State ==================== | |||||
| const fileInput = ref(null) | |||||
| const previewImage = ref('') | |||||
| const uploading = ref(false) | |||||
| const submitting = ref(false) | |||||
| const styleList = ref([{ value: '201', name: '默认', model: 2, pic: '' }]) | |||||
| const styleLoading = ref(false) | |||||
| const styleExpanded = ref(false) | |||||
| const STYLE_COLLAPSED_COUNT = 8 // 2 行 × 4 列(桌面) | |||||
| const visibleStyleList = computed(() => | |||||
| styleExpanded.value ? styleList.value : styleList.value.slice(0, STYLE_COLLAPSED_COUNT) | |||||
| ) | |||||
| const hasMoreStyles = computed(() => styleList.value.length > STYLE_COLLAPSED_COUNT) | |||||
| const balance = reactive({ points: 0 }) | |||||
| const quote = reactive({ points: 0 }) | |||||
| const chatOpenId = ref('') | |||||
| const demoPair = ref({ original: '', anime: '' }) | |||||
| const resultState = ref({ visible: false, loading: false, error: false, success: false, errorMsg: '' }) | |||||
| const genResultUrl = ref('') | |||||
| let pollTimer = null | |||||
| let countTimer = null | |||||
| const pollCountdown = ref(3) | |||||
| // ==================== Computed ==================== | |||||
| const canSubmit = computed(() => form.imgs.length > 0 && !uploading.value) | |||||
| const currentPoints = computed(() => Number(balance.points || 0)) | |||||
| const isBalanceInsufficient = computed(() => | |||||
| user.isLogin.value && Number(quote.points || 0) > 0 && currentPoints.value >= 0 && currentPoints.value < Number(quote.points || 0) | |||||
| ) | |||||
| const balanceText = computed(() => { | |||||
| const c = Number(quote.points || 0) | |||||
| if (!user.isLogin.value) return c ? `动漫头像 · ${c}点/张` : '动漫头像' | |||||
| return c ? `余额${currentPoints.value}点 · 预计消耗${c}点` : `余额${currentPoints.value}点` | |||||
| }) | |||||
| const submitText = computed(() => { | |||||
| const c = Number(quote.points || 0) | |||||
| if (!user.isLogin.value) return c ? `登录后生成 · ${c}点` : '登录后生成' | |||||
| return c ? `立即生成 · ${c}点` : '立即生成' | |||||
| }) | |||||
| const strengthCopy = computed(() => '推荐 0.6 - 0.8,保留人脸特征') | |||||
| // ==================== Upload ==================== | |||||
| function triggerUpload() { | |||||
| if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return } | |||||
| fileInput.value?.click() | |||||
| } | |||||
| function onFileChange(e) { | |||||
| const f = e.target.files?.[0] | |||||
| if (f) handleFile(f) | |||||
| e.target.value = '' | |||||
| } | |||||
| function onDrop(e) { | |||||
| const f = e.dataTransfer?.files?.[0] | |||||
| if (f) handleFile(f) | |||||
| } | |||||
| async function handleFile(file) { | |||||
| if (uploading.value) return | |||||
| uploading.value = true | |||||
| try { | |||||
| previewImage.value = URL.createObjectURL(file) | |||||
| const r = await uploadImage(file) | |||||
| if (r.data?.url) { | |||||
| form.imgs = [r.data.url] | |||||
| if (r.data.width) form.width = r.data.width | |||||
| if (r.data.height) form.height = r.data.height | |||||
| } else { | |||||
| previewImage.value = '' | |||||
| showToast(r.msg || '上传失败') | |||||
| } | |||||
| } catch (e) { | |||||
| previewImage.value = '' | |||||
| form.imgs = [] | |||||
| showToast(e.msg || e.message || '上传失败') | |||||
| } finally { | |||||
| uploading.value = false | |||||
| } | |||||
| } | |||||
| // ==================== Style List (无需登录) ==================== | |||||
| async function loadStyles() { | |||||
| styleLoading.value = true | |||||
| try { | |||||
| const r = await api.post('/anime/getstylelist', { model: '2' }) | |||||
| const rows = Array.isArray(r.list) ? r.list : (Array.isArray(r.data) ? r.data : []) | |||||
| const list = rows.map((item) => ({ | |||||
| ...item, value: String(item.value), | |||||
| pic: item.pic || item.img || item.image || item.cover || item.icon || item.url || '', | |||||
| })) | |||||
| if (list.length) { | |||||
| styleList.value = list | |||||
| if (!form.img2img_style || !list.some(s => s.value === form.img2img_style)) { | |||||
| selectStyle(list[0], false) | |||||
| } | |||||
| } | |||||
| } catch (e) { console.error('[anime-avatar] loadStyles failed:', e) } | |||||
| styleLoading.value = false | |||||
| } | |||||
| function selectStyle(item, resetStrength = true) { | |||||
| form.img2img_style = String(item.value) | |||||
| form.ai_type = 3 | |||||
| if (resetStrength) form.img2img_strength = 0.6 | |||||
| } | |||||
| // ==================== Demo (for hero compare) ==================== | |||||
| async function loadDemo() { | |||||
| try { | |||||
| const r = await api.post('/common/getconfig', { keys: 'anime_demo_v2,anime_demo' }) | |||||
| const rows = Array.isArray(r.list) ? r.list : [] | |||||
| const v2 = rows.find(it => it.key === 'anime_demo_v2') | |||||
| const old = rows.find(it => it.key === 'anime_demo') | |||||
| let list = [] | |||||
| if (v2 && v2.value) { | |||||
| try { | |||||
| const data = typeof v2.value === 'string' ? JSON.parse(v2.value) : v2.value | |||||
| const arr = Array.isArray(data) ? data : [data] | |||||
| list = arr.map(it => ({ | |||||
| original: it.original || it.before || it.source || '', | |||||
| anime: it.anime || it.after || it.result || '', | |||||
| })).filter(it => it.original && it.anime).slice(0, 1) | |||||
| } catch {} | |||||
| } | |||||
| if (!list.length && old && old.value) { | |||||
| const pics = String(old.value).split(',').map(s => s.trim()).filter(Boolean) | |||||
| if (pics.length >= 2) list = [{ original: pics[0], anime: pics[1] }] | |||||
| } | |||||
| if (list.length) demoPair.value = list[0] | |||||
| } catch (e) { console.error('[anime-avatar] loadDemo failed:', e) } | |||||
| } | |||||
| // ==================== Billing ==================== | |||||
| async function loadBilling() { | |||||
| try { | |||||
| const r = await api.post('/billing/config', { app_key: 'anime-avatar', model: ANIME_BILLING_MODEL }) | |||||
| if (r.code === 0 && r.data) { | |||||
| quote.points = r.data.quote?.points || 0 | |||||
| if (r.data.balance) balance.points = r.data.balance.points || r.data.balance.point_balance || 0 | |||||
| } | |||||
| } catch (e) { console.error('[anime-avatar] loadBilling failed:', e) } | |||||
| } | |||||
| async function loadBalance() { | |||||
| try { | |||||
| const r = await api.post('/user/getbalance', {}) | |||||
| if (r.code === 0 && r.data) balance.points = r.data.points || r.data.point_balance || 0 | |||||
| } catch (e) { console.error('[anime-avatar] loadBalance failed:', e) } | |||||
| } | |||||
| onMounted(() => { | |||||
| loadStyles() | |||||
| loadBilling() | |||||
| loadDemo() | |||||
| if (user.isLogin.value) loadBalance() | |||||
| }) | |||||
| onUnmounted(() => clearPoll()) | |||||
| // ==================== Compare Modal ==================== | |||||
| const compareVisible = ref(false) | |||||
| const comparePercent = ref(50) | |||||
| const largeCompareRef = ref(null) | |||||
| const compareTarget = ref('demo') // 'demo' | 'result' | |||||
| let dragging = false | |||||
| const comparePair = computed(() => { | |||||
| if (compareTarget.value === 'result' && previewImage.value && genResultUrl.value) { | |||||
| return { original: previewImage.value, anime: genResultUrl.value } | |||||
| } | |||||
| return demoPair.value | |||||
| }) | |||||
| function openCompare() { | |||||
| if (!demoPair.value.original || !demoPair.value.anime) { showToast('暂无对比图'); return } | |||||
| comparePercent.value = 50 | |||||
| compareTarget.value = 'demo' | |||||
| compareVisible.value = true | |||||
| } | |||||
| function openResultCompare() { | |||||
| if (!previewImage.value || !genResultUrl.value) return | |||||
| comparePercent.value = 50 | |||||
| compareTarget.value = 'result' | |||||
| compareVisible.value = true | |||||
| } | |||||
| function closeCompare() { compareVisible.value = false; dragging = false } | |||||
| function onCompareStart(e) { | |||||
| dragging = true | |||||
| updateComparePercent(e) | |||||
| window.addEventListener('mousemove', onCompareMove) | |||||
| window.addEventListener('mouseup', onCompareEnd) | |||||
| window.addEventListener('touchmove', onCompareMove, { passive: false }) | |||||
| window.addEventListener('touchend', onCompareEnd) | |||||
| } | |||||
| function onCompareMove(e) { | |||||
| if (!dragging) return | |||||
| if (e.cancelable) e.preventDefault() | |||||
| updateComparePercent(e.touches?.[0] || e) | |||||
| } | |||||
| function onCompareEnd() { | |||||
| dragging = false | |||||
| window.removeEventListener('mousemove', onCompareMove) | |||||
| window.removeEventListener('mouseup', onCompareEnd) | |||||
| window.removeEventListener('touchmove', onCompareMove) | |||||
| window.removeEventListener('touchend', onCompareEnd) | |||||
| } | |||||
| function updateComparePercent(e) { | |||||
| if (!largeCompareRef.value) return | |||||
| const rect = largeCompareRef.value.getBoundingClientRect() | |||||
| const x = (e.clientX || 0) - rect.left | |||||
| const percent = (x / rect.width) * 100 | |||||
| comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1)))) | |||||
| } | |||||
| // ==================== Submit ==================== | |||||
| function clearPoll() { | |||||
| clearTimeout(pollTimer) | |||||
| clearInterval(countTimer) | |||||
| } | |||||
| async function startPoll() { | |||||
| clearPoll() | |||||
| pollCountdown.value = 3 | |||||
| countTimer = setInterval(() => { pollCountdown.value = Math.max(0, pollCountdown.value - 1) }, 1000) | |||||
| pollTimer = setTimeout(async () => { | |||||
| try { | |||||
| const r = await api.post('/anime/getdetail', { chat_open_id: chatOpenId.value }) | |||||
| const d = r.data || {} | |||||
| const s = Number(d.is_suc) | |||||
| if (s > 0) { | |||||
| genResultUrl.value = d.answer || d.result_url || d.url || '' | |||||
| resultState.value = { visible: true, loading: false, error: false, success: true, errorMsg: '' } | |||||
| clearPoll() | |||||
| loadBalance() | |||||
| } else if (s < 0) { | |||||
| resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: d.err_msg || d.msg || '生成失败,请重试' } | |||||
| clearPoll() | |||||
| } else { | |||||
| startPoll() | |||||
| } | |||||
| } catch (e) { | |||||
| resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: e.msg || e.message || '查询失败' } | |||||
| } | |||||
| }, 3000) | |||||
| } | |||||
| async function handleSubmit() { | |||||
| if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return } | |||||
| if (uploading.value) { showToast('图片正在上传'); return } | |||||
| if (!form.imgs.length) { showToast('请上传参考图'); return } | |||||
| if (submitting.value) return | |||||
| submitting.value = true | |||||
| resultState.value = { visible: true, loading: true, error: false, success: false, errorMsg: '' } | |||||
| genResultUrl.value = '' | |||||
| try { | |||||
| const params = { ...form, imgs: form.imgs.slice(0, 1).join(',') } | |||||
| const r = await api.post('/anime/send', params) | |||||
| if (r.code === 1011) { | |||||
| resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: '点数不足,请充值后重试' } | |||||
| submitting.value = false | |||||
| return | |||||
| } | |||||
| if (r.code === 1012) { | |||||
| form.content = '' | |||||
| resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: r.msg || '内容审核不通过,请调整后重试' } | |||||
| submitting.value = false | |||||
| return | |||||
| } | |||||
| if (r.code !== 0) { | |||||
| resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: r.msg || '提交失败' } | |||||
| submitting.value = false | |||||
| return | |||||
| } | |||||
| chatOpenId.value = r.data?.answer_chat_open_id || r.data?.chat_open_id || '' | |||||
| if (!chatOpenId.value) { | |||||
| resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: '未获取到任务ID' } | |||||
| submitting.value = false | |||||
| return | |||||
| } | |||||
| startPoll() | |||||
| } catch (e) { | |||||
| resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: e.msg || e.message || '提交失败' } | |||||
| } finally { | |||||
| submitting.value = false | |||||
| } | |||||
| } | |||||
| async function downloadResult() { | |||||
| if (!genResultUrl.value) return | |||||
| try { | |||||
| const r = await fetch(genResultUrl.value) | |||||
| const b = await r.blob() | |||||
| const a = document.createElement('a') | |||||
| a.href = URL.createObjectURL(b) | |||||
| a.download = `anime-avatar-${Date.now()}.png` | |||||
| a.click() | |||||
| URL.revokeObjectURL(a.href) | |||||
| } catch { | |||||
| window.open(genResultUrl.value, '_blank') | |||||
| } | |||||
| } | |||||
| function goRecharge() { router.push('/recharge') } | |||||
| function goHistory() { | |||||
| if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return } | |||||
| router.push('/works') | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .animate-pulse-dot { animation: dotPulse 1.1s ease-in-out infinite; } | |||||
| .animate-pulse-dot2 { animation: dotPulse 1.1s ease-in-out 0.15s infinite; } | |||||
| .animate-pulse-dot3 { animation: dotPulse 1.1s ease-in-out 0.3s infinite; } | |||||
| @keyframes dotPulse { | |||||
| 0%, 100% { opacity: 0.35; transform: scale(0.82); } | |||||
| 50% { opacity: 1; transform: scale(1); } | |||||
| } | |||||
| .modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; } | |||||
| .modal-enter-from, .modal-leave-to { opacity: 0; } | |||||
| </style> | |||||
| @@ -0,0 +1,174 @@ | |||||
| <template> | |||||
| <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12"> | |||||
| <h1 class="text-2xl sm:text-3xl font-bold text-slate-900 mb-2">账单明细</h1> | |||||
| <p class="text-sm text-slate-500 mb-8">查看你的点数变动记录</p> | |||||
| <!-- 账单统计 --> | |||||
| <div class="grid grid-cols-3 gap-4 mb-8"> | |||||
| <div class="bg-white rounded-xl p-4 shadow-sm border border-slate-100 text-center"> | |||||
| <p class="text-xs text-slate-400">累计充值</p> | |||||
| <p class="mt-1 text-xl font-bold text-green-600">{{ summary.totalRecharge }}</p> | |||||
| </div> | |||||
| <div class="bg-white rounded-xl p-4 shadow-sm border border-slate-100 text-center"> | |||||
| <p class="text-xs text-slate-400">累计消费</p> | |||||
| <p class="mt-1 text-xl font-bold text-slate-900">{{ summary.totalConsume }}</p> | |||||
| </div> | |||||
| <div class="bg-white rounded-xl p-4 shadow-sm border border-slate-100 text-center"> | |||||
| <p class="text-xs text-slate-400">累计奖励</p> | |||||
| <p class="mt-1 text-xl font-bold text-indigo-600">{{ summary.totalReward }}</p> | |||||
| </div> | |||||
| </div> | |||||
| <!-- Tab 筛选 --> | |||||
| <div class="flex gap-2 overflow-x-auto pb-2 mb-4"> | |||||
| <button | |||||
| v-for="tab in billTabs" | |||||
| :key="tab.key" | |||||
| :class="[ | |||||
| 'shrink-0 px-4 py-2 text-sm rounded-lg transition-colors cursor-pointer', | |||||
| activeType === tab.key | |||||
| ? 'bg-indigo-500 text-white font-medium' | |||||
| : 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50', | |||||
| ]" | |||||
| @click="activeType = tab.key" | |||||
| > | |||||
| {{ tab.label }} | |||||
| </button> | |||||
| </div> | |||||
| <!-- 加载中 --> | |||||
| <div v-if="loading" class="flex justify-center py-20"> | |||||
| <svg class="w-8 h-8 text-indigo-500 animate-spin" fill="none" viewBox="0 0 24 24"> | |||||
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> | |||||
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> | |||||
| </svg> | |||||
| </div> | |||||
| <!-- 账单列表 --> | |||||
| <div v-else-if="bills.length > 0" class="bg-white rounded-xl shadow-sm border border-slate-100 divide-y divide-slate-100"> | |||||
| <div | |||||
| v-for="bill in bills" | |||||
| :key="bill.id" | |||||
| class="flex items-center justify-between px-5 py-4 hover:bg-slate-50/50 transition-colors" | |||||
| > | |||||
| <div class="min-w-0 flex-1"> | |||||
| <p class="text-sm font-medium text-slate-800 truncate">{{ bill.remark || bill.type_name || '账单记录' }}</p> | |||||
| <p class="mt-0.5 text-xs text-slate-400">{{ formatDate(bill.created_at) }}</p> | |||||
| </div> | |||||
| <div class="shrink-0 text-right ml-4"> | |||||
| <p :class="[ | |||||
| 'text-sm font-semibold', | |||||
| bill.points > 0 ? 'text-green-600' : bill.points < 0 ? 'text-slate-700' : 'text-slate-400', | |||||
| ]"> | |||||
| {{ bill.points > 0 ? '+' : '' }}{{ bill.points }} | |||||
| </p> | |||||
| <p class="text-xs text-slate-400">余额 {{ bill.balance }}</p> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 空状态 --> | |||||
| <div v-else class="text-center py-20"> | |||||
| <svg class="w-16 h-16 mx-auto text-slate-200 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |||||
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" /> | |||||
| </svg> | |||||
| <p class="text-sm text-slate-500">暂无账单记录</p> | |||||
| </div> | |||||
| <!-- 加载更多 --> | |||||
| <div v-if="hasMore" class="flex justify-center mt-6"> | |||||
| <button | |||||
| class="px-6 py-2.5 text-sm text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg transition-colors cursor-pointer" | |||||
| @click="loadMore" | |||||
| > | |||||
| 加载更多 | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| useSeoMeta({ | |||||
| title: '账单明细 - 奇想宇宙', | |||||
| description: '查看账户点数变动明细', | |||||
| }) | |||||
| const { isLogin } = useUser() | |||||
| const { post } = useApi() | |||||
| const billTabs = [ | |||||
| { label: '全部', key: 'all' }, | |||||
| { label: '充值', key: 'recharge' }, | |||||
| { label: '消费', key: 'consume' }, | |||||
| { label: '奖励', key: 'reward' }, | |||||
| ] | |||||
| const activeType = ref('all') | |||||
| const loading = ref(true) | |||||
| const bills = ref([]) | |||||
| const page = ref(1) | |||||
| const hasMore = ref(false) | |||||
| const summary = reactive({ | |||||
| totalRecharge: 0, | |||||
| totalConsume: 0, | |||||
| totalReward: 0, | |||||
| }) | |||||
| function formatDate(dateStr) { | |||||
| if (!dateStr) return '' | |||||
| return String(dateStr).substring(0, 10) | |||||
| } | |||||
| async function loadBills(reset = false) { | |||||
| if (reset) { | |||||
| page.value = 1 | |||||
| bills.value = [] | |||||
| } | |||||
| loading.value = true | |||||
| try { | |||||
| const res = await post('/user/getbills', { | |||||
| type: activeType.value === 'all' ? '' : activeType.value, | |||||
| page: page.value, | |||||
| page_size: 20, | |||||
| client: 1, | |||||
| }) | |||||
| if (res.code === 0) { | |||||
| const list = res.list || res.data || [] | |||||
| if (reset) { | |||||
| bills.value = list | |||||
| } else { | |||||
| bills.value.push(...list) | |||||
| } | |||||
| hasMore.value = list.length >= 20 | |||||
| // 更新统计 | |||||
| if (res.data?.summary) { | |||||
| summary.totalRecharge = res.data.summary.recharge || 0 | |||||
| summary.totalConsume = Math.abs(res.data.summary.consume || 0) | |||||
| summary.totalReward = res.data.summary.reward || 0 | |||||
| } | |||||
| } | |||||
| } catch { | |||||
| // 忽略 | |||||
| } finally { | |||||
| loading.value = false | |||||
| } | |||||
| } | |||||
| function loadMore() { | |||||
| page.value++ | |||||
| loadBills(false) | |||||
| } | |||||
| watch(activeType, () => { | |||||
| loadBills(true) | |||||
| }) | |||||
| onMounted(() => { | |||||
| if (isLogin.value) { | |||||
| loadBills(true) | |||||
| } | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,105 @@ | |||||
| <template> | |||||
| <div> | |||||
| <!-- Hero --> | |||||
| <section class="relative overflow-hidden bg-white"> | |||||
| <div class="absolute inset-0 bg-gradient-to-br from-indigo-50/60 via-white to-violet-50/40" /> | |||||
| <div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 sm:py-28 lg:py-36"> | |||||
| <div class="max-w-3xl mx-auto text-center"> | |||||
| <h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-slate-900"> | |||||
| 用 AI 打开 | |||||
| <span class="text-indigo-500">创意</span> | |||||
| 之门 | |||||
| </h1> | |||||
| <p class="mt-6 text-lg sm:text-xl text-slate-500 leading-relaxed max-w-2xl mx-auto"> | |||||
| AI 问答、AI 绘画、AI 音乐、AI 取名、动漫头像 —— | |||||
| 一站式 AI 创作平台,让每个人都能轻松创作。 | |||||
| </p> | |||||
| <div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4"> | |||||
| <NuxtLink | |||||
| to="/ai-name" | |||||
| class="w-full sm:w-auto inline-flex items-center justify-center px-8 py-3.5 text-base font-semibold text-white bg-indigo-500 hover:bg-indigo-600 rounded-xl shadow-sm hover:shadow-md transition-all duration-200 cursor-pointer" | |||||
| > | |||||
| 开始创作 | |||||
| </NuxtLink> | |||||
| <NuxtLink | |||||
| to="/recharge" | |||||
| class="w-full sm:w-auto inline-flex items-center justify-center px-8 py-3.5 text-base font-semibold text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-xl transition-colors duration-200 cursor-pointer" | |||||
| > | |||||
| 查看套餐 | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </section> | |||||
| <!-- 功能卡片 --> | |||||
| <section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20"> | |||||
| <div class="text-center mb-12"> | |||||
| <h2 class="text-2xl sm:text-3xl font-bold text-slate-900">AI 创作工具</h2> | |||||
| <p class="mt-3 text-slate-500">选择你需要的 AI 能力,开始创作之旅</p> | |||||
| </div> | |||||
| <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> | |||||
| <NuxtLink | |||||
| v-for="tool in tools" | |||||
| :key="tool.title" | |||||
| :to="tool.to" | |||||
| class="group relative bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md hover:border-indigo-100 transition-all duration-300 cursor-pointer" | |||||
| > | |||||
| <div class="w-12 h-12 rounded-xl bg-indigo-50 flex items-center justify-center mb-4 group-hover:bg-indigo-100 transition-colors duration-200"> | |||||
| <component :is="tool.icon" class="w-6 h-6 text-indigo-500" /> | |||||
| </div> | |||||
| <h3 class="text-lg font-semibold text-slate-900 mb-2">{{ tool.title }}</h3> | |||||
| <p class="text-sm text-slate-500 leading-relaxed">{{ tool.desc }}</p> | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </section> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| useSeoMeta({ | |||||
| title: '奇想宇宙 - AI 创作平台', | |||||
| description: 'AI 问答、AI 绘画、AI 音乐、AI 取名、动漫头像 —— 一站式 AI 创作平台', | |||||
| keywords: 'AI,人工智能,Midjourney,AI绘画,AI音乐,AI取名', | |||||
| }) | |||||
| const tools = [ | |||||
| { | |||||
| title: 'AI 取名', | |||||
| desc: '智能取名,结合姓氏、性别、出生日期,从诗经楚辞中精选好名字', | |||||
| to: '/ai-name', | |||||
| icon: 'div', | |||||
| }, | |||||
| { | |||||
| title: 'AI 动漫头像', | |||||
| desc: '上传自拍,一键生成专属动漫风格头像,多种风格可选', | |||||
| to: '/anime-avatar', | |||||
| icon: 'div', | |||||
| }, | |||||
| { | |||||
| title: 'AI 绘画', | |||||
| desc: '使用 Midjourney 生成高质量 AI 绘画作品,激发无限创意', | |||||
| to: '#', | |||||
| icon: 'div', | |||||
| }, | |||||
| { | |||||
| title: 'AI 音乐', | |||||
| desc: 'AI 生成原创音乐,多种风格随心选择', | |||||
| to: '#', | |||||
| icon: 'div', | |||||
| }, | |||||
| { | |||||
| title: 'AI 问答', | |||||
| desc: '智能问答助手,随时随地解答你的疑问', | |||||
| to: '#', | |||||
| icon: 'div', | |||||
| }, | |||||
| { | |||||
| title: '更多工具', | |||||
| desc: '文本转语音、AI 装修设计等更多 AI 能力持续上线中', | |||||
| to: '#', | |||||
| icon: 'div', | |||||
| }, | |||||
| ] | |||||
| </script> | |||||
| @@ -0,0 +1,242 @@ | |||||
| <template> | |||||
| <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12"> | |||||
| <!-- 标题 --> | |||||
| <div class="text-center mb-8"> | |||||
| <h1 class="text-2xl sm:text-3xl font-bold text-slate-900">充值中心</h1> | |||||
| <p class="mt-2 text-sm text-slate-500">为你的 AI 创作充能</p> | |||||
| </div> | |||||
| <!-- 余额 --> | |||||
| <div class="max-w-sm mx-auto mb-8 px-6 py-4 bg-white rounded-2xl shadow-sm border border-slate-100 text-center"> | |||||
| <span class="text-sm text-slate-500">当前余额</span> | |||||
| <div class="mt-1 text-3xl font-bold text-indigo-600">{{ userInfo.balance || 0 }} <span class="text-base font-normal text-slate-400">点</span></div> | |||||
| </div> | |||||
| <!-- 套餐列表 --> | |||||
| <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8"> | |||||
| <div | |||||
| v-for="(pkg, idx) in products" | |||||
| :key="pkg.id" | |||||
| :class="[ | |||||
| 'relative bg-white rounded-2xl p-6 border transition-all duration-200 cursor-pointer text-center', | |||||
| selectedId === pkg.id | |||||
| ? 'border-indigo-400 shadow-md ring-2 ring-indigo-500/20' | |||||
| : 'border-slate-100 shadow-sm hover:shadow-md hover:border-indigo-100', | |||||
| ]" | |||||
| @click="selectProduct(pkg)" | |||||
| > | |||||
| <div | |||||
| v-if="idx === 1" | |||||
| class="absolute -top-2.5 left-1/2 -translate-x-1/2 px-3 py-0.5 bg-green-500 text-white text-xs font-medium rounded-full" | |||||
| > | |||||
| 推荐 | |||||
| </div> | |||||
| <div class="text-lg font-semibold text-slate-900">{{ pkg.points || pkg.point }} 点</div> | |||||
| <div class="mt-1 text-2xl font-bold text-slate-900">¥{{ pkg.price }}</div> | |||||
| <div v-if="pkg.discount" class="mt-1 text-xs text-green-600">{{ pkg.discount }}</div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 支付按钮 --> | |||||
| <div class="max-w-sm mx-auto"> | |||||
| <button | |||||
| v-if="!isPaying" | |||||
| :disabled="!selectedId" | |||||
| class="w-full py-3 text-sm font-semibold text-white bg-indigo-500 hover:bg-indigo-600 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-xl transition-colors cursor-pointer" | |||||
| @click="startPay" | |||||
| > | |||||
| 确认支付 | |||||
| </button> | |||||
| <!-- 支付弹窗内嵌 --> | |||||
| <div v-if="isPaying" class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> | |||||
| <div class="text-center"> | |||||
| <p class="text-sm font-medium text-slate-700 mb-4">微信扫码支付</p> | |||||
| <!-- 二维码 --> | |||||
| <div class="w-40 h-40 mx-auto mb-4 flex items-center justify-center"> | |||||
| <div v-if="payState === 'loading'" class="flex flex-col items-center gap-2"> | |||||
| <svg class="w-8 h-8 text-indigo-500 animate-spin" fill="none" viewBox="0 0 24 24"> | |||||
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> | |||||
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> | |||||
| </svg> | |||||
| <span class="text-xs text-slate-400">生成二维码...</span> | |||||
| </div> | |||||
| <vue-qr | |||||
| v-else-if="codeUrl" | |||||
| :text="codeUrl" | |||||
| :size="160" | |||||
| :margin="8" | |||||
| /> | |||||
| <div v-else class="flex flex-col items-center gap-2"> | |||||
| <svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |||||
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> | |||||
| </svg> | |||||
| <span class="text-xs text-red-400">生成失败</span> | |||||
| </div> | |||||
| </div> | |||||
| <p v-if="payState === 'pending'" class="text-sm text-slate-500 mb-1"> | |||||
| 请使用微信扫描二维码 | |||||
| </p> | |||||
| <p v-else-if="payState === 'success'" class="text-sm text-green-600 font-medium"> | |||||
| 支付成功!🎉 | |||||
| </p> | |||||
| <p v-if="payState !== 'success'" class="text-xs text-slate-400">支付金额:¥{{ selectedPrice }}</p> | |||||
| </div> | |||||
| <!-- 操作按钮 --> | |||||
| <div class="flex justify-center gap-3 mt-4"> | |||||
| <button | |||||
| v-if="payState !== 'success'" | |||||
| class="px-4 py-2 text-xs text-slate-400 hover:text-slate-600 cursor-pointer" | |||||
| @click="cancelPay" | |||||
| > | |||||
| 取消支付 | |||||
| </button> | |||||
| <button | |||||
| v-if="payState === 'success'" | |||||
| class="px-4 py-2 text-xs text-white bg-green-500 hover:bg-green-600 rounded-lg cursor-pointer" | |||||
| @click="finishPaySuccess" | |||||
| > | |||||
| 完成 | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| import VueQr from 'vue-qr' | |||||
| useSeoMeta({ | |||||
| title: '充值中心 - 奇想宇宙', | |||||
| description: '选择套餐,为AI创作充值点数', | |||||
| }) | |||||
| const { userInfo, isLogin } = useUser() | |||||
| const { post } = useApi() | |||||
| // 套餐列表 | |||||
| const products = ref([]) | |||||
| const selectedId = ref(null) | |||||
| const selectedPrice = ref(0) | |||||
| // 支付状态 | |||||
| const isPaying = ref(false) | |||||
| const payState = ref('idle') // idle | loading | pending | success | error | |||||
| const codeUrl = ref('') | |||||
| const orderNo = ref('') | |||||
| let pollingTimer = null | |||||
| // 获取套餐列表 | |||||
| async function loadProducts() { | |||||
| try { | |||||
| const res = await post('/vip/getproductlist') | |||||
| if (res.code === 0) { | |||||
| products.value = (res.list || res.data || []).map((p) => ({ | |||||
| ...p, | |||||
| point: p.point || p.points, | |||||
| })) | |||||
| } | |||||
| } catch { | |||||
| products.value = [] | |||||
| } | |||||
| } | |||||
| // 选择套餐 | |||||
| function selectProduct(pkg) { | |||||
| selectedId.value = pkg.id | |||||
| selectedPrice.value = pkg.price | |||||
| } | |||||
| // 开始支付(对齐老 PC 的 buy 流程) | |||||
| async function startPay() { | |||||
| if (!selectedId.value) return | |||||
| payState.value = 'loading' | |||||
| isPaying.value = true | |||||
| try { | |||||
| const res = await post('/vip/createorder', { | |||||
| product_id: selectedId.value, | |||||
| client: 1, | |||||
| }) | |||||
| if (res.code === 0 && res.data?.code_url) { | |||||
| codeUrl.value = res.data.code_url | |||||
| orderNo.value = res.data.order_no || '' | |||||
| payState.value = 'pending' | |||||
| startPolling() | |||||
| } else { | |||||
| payState.value = 'error' | |||||
| } | |||||
| } catch { | |||||
| payState.value = 'error' | |||||
| } | |||||
| } | |||||
| // 轮询支付结果(对齐老 PC:每 2 秒轮询 paysuccess) | |||||
| function startPolling() { | |||||
| clearInterval(pollingTimer) | |||||
| pollingTimer = setInterval(async () => { | |||||
| try { | |||||
| const res = await post('/vip/paysuccess', { order_no: orderNo.value }) | |||||
| // status == 2 表示支付成功 | |||||
| if (res.code === 0 && (res.data?.status == 2 || res.data?.paid === true)) { | |||||
| clearInterval(pollingTimer) | |||||
| payState.value = 'success' | |||||
| // 刷新余额 | |||||
| await refreshUserInfo() | |||||
| } | |||||
| } catch { | |||||
| // 轮询失败不中断 | |||||
| } | |||||
| }, 2000) | |||||
| } | |||||
| // 取消支付 | |||||
| function cancelPay() { | |||||
| clearInterval(pollingTimer) | |||||
| isPaying.value = false | |||||
| payState.value = 'idle' | |||||
| codeUrl.value = '' | |||||
| orderNo.value = '' | |||||
| } | |||||
| // 支付成功完成 | |||||
| function finishPaySuccess() { | |||||
| clearInterval(pollingTimer) | |||||
| isPaying.value = false | |||||
| payState.value = 'idle' | |||||
| codeUrl.value = '' | |||||
| orderNo.value = '' | |||||
| selectedId.value = null | |||||
| } | |||||
| // 刷新用户余额 | |||||
| async function refreshUserInfo() { | |||||
| try { | |||||
| const res = await post('/user/getuserinfo') | |||||
| if (res.code === 0 && res.data) { | |||||
| userInfo.value.balance = res.data.balance || res.data.point || 0 | |||||
| } | |||||
| } catch { | |||||
| // 忽略 | |||||
| } | |||||
| } | |||||
| onMounted(() => { | |||||
| loadProducts() | |||||
| if (isLogin.value) { | |||||
| refreshUserInfo() | |||||
| } | |||||
| }) | |||||
| onUnmounted(() => { | |||||
| clearInterval(pollingTimer) | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,160 @@ | |||||
| <template> | |||||
| <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12"> | |||||
| <h1 class="text-2xl sm:text-3xl font-bold text-slate-900 mb-2">我的作品</h1> | |||||
| <p class="text-sm text-slate-500 mb-8">查看你所有的 AI 创作成果</p> | |||||
| <!-- Tab 分类 --> | |||||
| <div class="flex gap-2 overflow-x-auto pb-2 mb-6"> | |||||
| <button | |||||
| v-for="tab in tabs" | |||||
| :key="tab.key" | |||||
| :class="[ | |||||
| 'shrink-0 px-4 py-2 text-sm rounded-lg transition-colors cursor-pointer', | |||||
| activeTab === tab.key | |||||
| ? 'bg-indigo-500 text-white font-medium' | |||||
| : 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50', | |||||
| ]" | |||||
| @click="activeTab = tab.key" | |||||
| > | |||||
| {{ tab.label }} | |||||
| </button> | |||||
| </div> | |||||
| <!-- 加载中 --> | |||||
| <div v-if="loading" class="flex justify-center py-20"> | |||||
| <svg class="w-8 h-8 text-indigo-500 animate-spin" fill="none" viewBox="0 0 24 24"> | |||||
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> | |||||
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> | |||||
| </svg> | |||||
| </div> | |||||
| <!-- 作品网格 --> | |||||
| <div v-else-if="works.length > 0" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"> | |||||
| <div | |||||
| v-for="work in works" | |||||
| :key="work.id" | |||||
| class="group bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden hover:shadow-md transition-all duration-200" | |||||
| > | |||||
| <div class="aspect-square bg-slate-100 relative overflow-hidden"> | |||||
| <img | |||||
| v-if="work.cover_url || work.url" | |||||
| :src="work.cover_url || work.url" | |||||
| :alt="work.name" | |||||
| class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" | |||||
| /> | |||||
| <div | |||||
| v-else-if="work.status === 'processing'" | |||||
| class="w-full h-full flex items-center justify-center bg-indigo-50" | |||||
| > | |||||
| <svg class="w-8 h-8 text-indigo-400 animate-spin" fill="none" viewBox="0 0 24 24"> | |||||
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> | |||||
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> | |||||
| </svg> | |||||
| </div> | |||||
| <div v-else class="w-full h-full flex items-center justify-center text-slate-300 text-sm"> | |||||
| {{ work.type_name || '作品' }} | |||||
| </div> | |||||
| </div> | |||||
| <div class="p-3"> | |||||
| <p class="text-sm font-medium text-slate-800 truncate">{{ work.name || '未命名' }}</p> | |||||
| <p class="mt-1 text-xs text-slate-400">{{ formatDate(work.created_at) }}</p> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 空状态 --> | |||||
| <div v-else class="text-center py-20"> | |||||
| <svg class="w-16 h-16 mx-auto text-slate-200 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |||||
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> | |||||
| </svg> | |||||
| <p class="text-sm text-slate-500">暂无作品,快去创作吧!</p> | |||||
| </div> | |||||
| <!-- 加载更多 --> | |||||
| <div v-if="hasMore" class="flex justify-center mt-8"> | |||||
| <button | |||||
| class="px-6 py-2.5 text-sm text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg transition-colors cursor-pointer" | |||||
| @click="loadMore" | |||||
| > | |||||
| 加载更多 | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| useSeoMeta({ | |||||
| title: '我的作品 - 奇想宇宙', | |||||
| description: '查看AI创作的所有作品', | |||||
| }) | |||||
| const { isLogin } = useUser() | |||||
| const { post } = useApi() | |||||
| const tabs = [ | |||||
| { label: '全部', key: 'all' }, | |||||
| { label: 'AI取名', key: 'name' }, | |||||
| { label: '动漫头像', key: 'avatar' }, | |||||
| { label: 'AI绘画', key: 'draw' }, | |||||
| ] | |||||
| const activeTab = ref('all') | |||||
| const loading = ref(true) | |||||
| const works = ref([]) | |||||
| const page = ref(1) | |||||
| const hasMore = ref(false) | |||||
| // 格式化日期 | |||||
| function formatDate(dateStr) { | |||||
| if (!dateStr) return '' | |||||
| return String(dateStr).substring(0, 10) | |||||
| } | |||||
| // 加载作品 | |||||
| async function loadWorks(reset = false) { | |||||
| if (reset) { | |||||
| page.value = 1 | |||||
| works.value = [] | |||||
| } | |||||
| loading.value = true | |||||
| try { | |||||
| const res = await post('/tool/getworks', { | |||||
| type: activeTab.value === 'all' ? '' : activeTab.value, | |||||
| page: page.value, | |||||
| page_size: 20, | |||||
| client: 1, | |||||
| }) | |||||
| if (res.code === 0) { | |||||
| const list = res.list || res.data || [] | |||||
| if (reset) { | |||||
| works.value = list | |||||
| } else { | |||||
| works.value.push(...list) | |||||
| } | |||||
| hasMore.value = list.length >= 20 | |||||
| } | |||||
| } catch { | |||||
| // 忽略 | |||||
| } finally { | |||||
| loading.value = false | |||||
| } | |||||
| } | |||||
| // 加载更多 | |||||
| function loadMore() { | |||||
| page.value++ | |||||
| loadWorks(false) | |||||
| } | |||||
| // 切换分类 | |||||
| watch(activeTab, () => { | |||||
| loadWorks(true) | |||||
| }) | |||||
| onMounted(() => { | |||||
| if (isLogin.value) { | |||||
| loadWorks(true) | |||||
| } | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,107 @@ | |||||
| /** | |||||
| * 零依赖 MD5 实现 | |||||
| * 直接从 ai_uniapp_v2/utils/md5.js 复用,用于生成请求 nonce | |||||
| * 用法:import md5 from '@/utils/md5.js' → md5('abc') | |||||
| */ | |||||
| function safeAdd(x, y) { | |||||
| const lsw = (x & 0xffff) + (y & 0xffff) | |||||
| const msw = (x >> 16) + (y >> 16) + (lsw >> 16) | |||||
| return (msw << 16) | (lsw & 0xffff) | |||||
| } | |||||
| function rotl(num, cnt) { | |||||
| return (num << cnt) | (num >>> (32 - cnt)) | |||||
| } | |||||
| function cmn(q, a, b, x, s, t) { | |||||
| return safeAdd(rotl(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b) | |||||
| } | |||||
| function ff(a, b, c, d, x, s, t) { return cmn((b & c) | (~b & d), a, b, x, s, t) } | |||||
| function gg(a, b, c, d, x, s, t) { return cmn((b & d) | (c & ~d), a, b, x, s, t) } | |||||
| function hh(a, b, c, d, x, s, t) { return cmn(b ^ c ^ d, a, b, x, s, t) } | |||||
| function ii(a, b, c, d, x, s, t) { return cmn(c ^ (b | ~d), a, b, x, s, t) } | |||||
| function coreMd5(x, len) { | |||||
| x[len >> 5] |= 0x80 << len % 32 | |||||
| x[(((len + 64) >>> 9) << 4) + 14] = len | |||||
| let a = 1732584193, b = -271733879, c = -1732584194, d = 271733878 | |||||
| for (let i = 0; i < x.length; i += 16) { | |||||
| const oa = a, ob = b, oc = c, od = d | |||||
| a = ff(a,b,c,d, x[i], 7, -680876936) | |||||
| d = ff(d,a,b,c, x[i+1], 12, -389564586) | |||||
| c = ff(c,d,a,b, x[i+2], 17, 606105819) | |||||
| b = ff(b,c,d,a, x[i+3], 22, -1044525330) | |||||
| a = ff(a,b,c,d, x[i+4], 7, -176418897) | |||||
| d = ff(d,a,b,c, x[i+5], 12, 1200080426) | |||||
| c = ff(c,d,a,b, x[i+6], 17, -1473231341) | |||||
| b = ff(b,c,d,a, x[i+7], 22, -45705983) | |||||
| a = ff(a,b,c,d, x[i+8], 7, 1770035416) | |||||
| d = ff(d,a,b,c, x[i+9], 12, -1958414417) | |||||
| c = ff(c,d,a,b, x[i+10],17, -42063) | |||||
| b = ff(b,c,d,a, x[i+11],22, -1990404162) | |||||
| a = ff(a,b,c,d, x[i+12], 7, 1804603682) | |||||
| d = ff(d,a,b,c, x[i+13],12, -40341101) | |||||
| c = ff(c,d,a,b, x[i+14],17, -1502002290) | |||||
| b = ff(b,c,d,a, x[i+15],22, 1236535329) | |||||
| a = gg(a,b,c,d, x[i+1], 5, -165796510) | |||||
| d = gg(d,a,b,c, x[i+6], 9, -1069501632) | |||||
| c = gg(c,d,a,b, x[i+11],14, 643717713) | |||||
| b = gg(b,c,d,a, x[i], 20, -373897302) | |||||
| a = gg(a,b,c,d, x[i+5], 5, -701558691) | |||||
| d = gg(d,a,b,c, x[i+10], 9, 38016083) | |||||
| c = gg(c,d,a,b, x[i+15],14, -660478335) | |||||
| b = gg(b,c,d,a, x[i+4], 20, -405537848) | |||||
| a = gg(a,b,c,d, x[i+9], 5, 568446438) | |||||
| d = gg(d,a,b,c, x[i+14], 9, -1019803690) | |||||
| c = gg(c,d,a,b, x[i+3], 14, -187363961) | |||||
| b = gg(b,c,d,a, x[i+8], 20, 1163531501) | |||||
| a = gg(a,b,c,d, x[i+13], 5, -1444681467) | |||||
| d = gg(d,a,b,c, x[i+2], 9, -51403784) | |||||
| c = gg(c,d,a,b, x[i+7], 14, 1735328473) | |||||
| b = gg(b,c,d,a, x[i+12],20, -1926607734) | |||||
| a = hh(a,b,c,d, x[i+5], 4, -378558) | |||||
| d = hh(d,a,b,c, x[i+8], 11, -2022574463) | |||||
| c = hh(c,d,a,b, x[i+11],16, 1839030562) | |||||
| b = hh(b,c,d,a, x[i+14],23, -35309556) | |||||
| a = hh(a,b,c,d, x[i+1], 4, -1530992060) | |||||
| d = hh(d,a,b,c, x[i+4], 11, 1272893353) | |||||
| c = hh(c,d,a,b, x[i+7], 16, -155497632) | |||||
| b = hh(b,c,d,a, x[i+10],23, -1094730640) | |||||
| a = hh(a,b,c,d, x[i+13], 4, 681279174) | |||||
| d = hh(d,a,b,c, x[i], 11, -358537222) | |||||
| c = hh(c,d,a,b, x[i+3], 16, -722521979) | |||||
| b = hh(b,c,d,a, x[i+6], 23, 76029189) | |||||
| a = hh(a,b,c,d, x[i+9], 4, -640364487) | |||||
| d = hh(d,a,b,c, x[i+12],11, -421815835) | |||||
| c = hh(c,d,a,b, x[i+15],16, 530742520) | |||||
| b = hh(b,c,d,a, x[i+2], 23, -995338651) | |||||
| a = ii(a,b,c,d, x[i], 6, -198630844) | |||||
| d = ii(d,a,b,c, x[i+7], 10, 1126891415) | |||||
| c = ii(c,d,a,b, x[i+14],15, -1416354905) | |||||
| b = ii(b,c,d,a, x[i+5], 21, -57434055) | |||||
| a = ii(a,b,c,d, x[i+12], 6, 1700485571) | |||||
| d = ii(d,a,b,c, x[i+3], 10, -1894986606) | |||||
| c = ii(c,d,a,b, x[i+10],15, -1051523) | |||||
| b = ii(b,c,d,a, x[i+1], 21, -2054922799) | |||||
| a = ii(a,b,c,d, x[i+8], 6, 1873313359) | |||||
| d = ii(d,a,b,c, x[i+15],10, -30611744) | |||||
| c = ii(c,d,a,b, x[i+6], 15, -1560198380) | |||||
| b = ii(b,c,d,a, x[i+13],21, 1309151649) | |||||
| a = ii(a,b,c,d, x[i+4], 6, -145523070) | |||||
| d = ii(d,a,b,c, x[i+11],10, -1120210379) | |||||
| c = ii(c,d,a,b, x[i+2], 15, 718787259) | |||||
| b = ii(b,c,d,a, x[i+9], 21, -343485551) | |||||
| a = safeAdd(a, oa); b = safeAdd(b, ob); c = safeAdd(c, oc); d = safeAdd(d, od) | |||||
| } | |||||
| return [a, b, c, d] | |||||
| } | |||||
| function binlToRstr(i) { let o = ''; for (let j = 0; j < i.length * 32; j += 8) o += String.fromCharCode((i[j >> 5] >>> j % 32) & 0xff); return o } | |||||
| function rstrToBinl(i) { const o = []; for (let j = 0; j < i.length * 8; j += 8) o[j >> 5] |= (i.charCodeAt(j / 8) & 0xff) << j % 32; return o } | |||||
| function rstrMd5(s) { return binlToRstr(coreMd5(rstrToBinl(s), s.length * 8)) } | |||||
| function rstrToHex(i) { const h = '0123456789abcdef'; let o = ''; for (let j = 0; j < i.length; j++) { const x = i.charCodeAt(j); o += h.charAt((x >>> 4) & 0x0f) + h.charAt(x & 0x0f) } return o } | |||||
| function strToUtf8(i) { return unescape(encodeURIComponent(i)) } | |||||
| export default function md5(str) { | |||||
| return rstrToHex(rstrMd5(strToUtf8(String(str)))) | |||||
| } | |||||
| @@ -0,0 +1,40 @@ | |||||
| // https://nuxt.com/docs/api/configuration/nuxt-config | |||||
| export default defineNuxtConfig({ | |||||
| compatibilityDate: '2025-07-15', | |||||
| devtools: { enabled: true }, | |||||
| devServer: { port: 6888, host: '0.0.0.0' }, | |||||
| modules: ['@nuxt/ui'], | |||||
| css: ['~/assets/css/main.css'], | |||||
| // 禁用 @nuxt/fonts 远程字体提供商(内网环境无法访问 Google Fonts) | |||||
| ui: { | |||||
| fonts: false, | |||||
| }, | |||||
| // 运行时公开配置(客户端可访问) | |||||
| runtimeConfig: { | |||||
| public: { | |||||
| // ThinkJS ai_api 地址,按环境覆盖 | |||||
| apiBase: process.env.API_BASE || 'http://192.168.31.168:16888', | |||||
| }, | |||||
| }, | |||||
| // SSR / SEO | |||||
| ssr: true, | |||||
| app: { | |||||
| head: { | |||||
| title: '奇想宇宙', | |||||
| meta: [ | |||||
| { charset: 'utf-8' }, | |||||
| { name: 'viewport', content: 'width=device-width, initial-scale=1' }, | |||||
| { name: 'description', content: 'AI 问答、AI 绘画、AI 音乐,探索无限可能' }, | |||||
| { name: 'keywords', content: 'AI,人工智能,Midjourney,AI绘画,AI音乐,AI问答' }, | |||||
| ], | |||||
| link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }], | |||||
| }, | |||||
| }, | |||||
| }) | |||||
| @@ -0,0 +1,23 @@ | |||||
| { | |||||
| "name": "pc_nuxt", | |||||
| "version": "1.0.0", | |||||
| "private": true, | |||||
| "type": "module", | |||||
| "scripts": { | |||||
| "dev": "nuxt dev", | |||||
| "build": "nuxt build", | |||||
| "generate": "nuxt generate", | |||||
| "preview": "nuxt preview" | |||||
| }, | |||||
| "dependencies": { | |||||
| "@internationalized/date": "^3.12.2", | |||||
| "dayjs": "^1.11.21", | |||||
| "lunar-javascript": "^1.7.7", | |||||
| "nuxt": "^4.0.0", | |||||
| "vue-qr": "^5.0.0" | |||||
| }, | |||||
| "devDependencies": { | |||||
| "@iconify-json/lucide": "^1.2.118", | |||||
| "@nuxt/ui": "^4.10.0" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,3 @@ | |||||
| allowBuilds: | |||||
| esbuild: set this to true or false | |||||
| vue-demi: set this to true or false | |||||
| @@ -0,0 +1,81 @@ | |||||
| /** | |||||
| * 服务端 API 代理 —— 将 /api/** 请求转发到 ThinkJS 后端 | |||||
| * | |||||
| * 对齐 ai_uniapp_v2 H5 端的 manifest.json devServer.proxy 行为: | |||||
| * /api/user/info → https://api.jiefuku.com/user/info | |||||
| * | |||||
| * 用途: | |||||
| * 1. 避免浏览器跨域 | |||||
| * 2. 服务端转发自动携带 cookie,保持登录态 | |||||
| * | |||||
| * 环境变量 API_BASE 可覆盖目标地址(生产环境指向 https://api.aionline.cc) | |||||
| */ | |||||
| export default defineEventHandler(async (event) => { | |||||
| const apiBase = process.env.API_BASE || 'http://192.168.31.168:16888' | |||||
| // 去掉 /api 前缀,得到真实后端路径 | |||||
| let path = event.path.replace(/^\/api/, '') || '/' | |||||
| if (!path.startsWith('/')) path = '/' + path | |||||
| const method = event.method | |||||
| const url = apiBase + path | |||||
| const headers = {} | |||||
| // 转发用户 cookie(保持登录态) | |||||
| const cookie = event.headers.get('cookie') | |||||
| if (cookie) { | |||||
| headers.cookie = cookie | |||||
| } | |||||
| // 透传客户端的 Content-Type(multipart/form-data 上传需要) | |||||
| const contentType = event.headers.get('content-type') | |||||
| if (contentType) { | |||||
| headers['content-type'] = contentType | |||||
| } | |||||
| // 透传签名的 header(nonce / timestr / token) | |||||
| const clientNonce = event.headers.get('nonce') | |||||
| const clientTimestr = event.headers.get('timestr') | |||||
| const clientToken = event.headers.get('token') | |||||
| if (clientNonce) headers.nonce = clientNonce | |||||
| if (clientTimestr) headers.timestr = clientTimestr | |||||
| if (clientToken) headers.token = clientToken | |||||
| const fetchOptions = { method, headers } | |||||
| // GET: 查询参数 | |||||
| const query = getQuery(event) | |||||
| const queryStr = new URLSearchParams(query).toString() | |||||
| const fullUrl = queryStr ? url + '?' + queryStr : url | |||||
| // POST: 请求体 | |||||
| if (method === 'POST') { | |||||
| const body = await readBody(event) | |||||
| if (body && typeof body === 'object') { | |||||
| // 如果是 FormData 上传(multipart),直接透传原始 body | |||||
| if (contentType && contentType.includes('multipart/form-data')) { | |||||
| // multipart 请求的 body 需要特殊处理,直接用原始 buffer | |||||
| const rawBody = await readRawBody(event) | |||||
| if (rawBody) { | |||||
| fetchOptions.body = rawBody | |||||
| // 保留原始 boundary | |||||
| fetchOptions.headers = { ...fetchOptions.headers, 'content-type': contentType } | |||||
| } | |||||
| } else { | |||||
| fetchOptions.body = new URLSearchParams(body).toString() | |||||
| } | |||||
| } | |||||
| } | |||||
| try { | |||||
| return await $fetch(fullUrl, fetchOptions) | |||||
| } catch (error) { | |||||
| console.error('[Server Proxy Error]', url, error.message) | |||||
| return { | |||||
| code: 1000, | |||||
| data: null, | |||||
| msg: '服务器代理请求失败', | |||||
| } | |||||
| } | |||||
| }) | |||||