Selaa lähdekoodia

init: pc_nuxt initial commit

main
leiyun 1 kuukausi sitten
commit
31cb912c3b
21 muutettua tiedostoa jossa 12692 lisäystä ja 0 poistoa
  1. +24
    -0
      .gitignore
  2. +126
    -0
      README.md
  3. +7
    -0
      app/app.vue
  4. +37
    -0
      app/assets/css/main.css
  5. +318
    -0
      app/components/LoginModal.vue
  6. +150
    -0
      app/composables/useApi.js
  7. +120
    -0
      app/composables/useUpload.js
  8. +122
    -0
      app/composables/useUser.js
  9. +93
    -0
      app/layouts/default.vue
  10. +724
    -0
      app/pages/ai-name.vue
  11. +644
    -0
      app/pages/anime-avatar.vue
  12. +174
    -0
      app/pages/bill.vue
  13. +105
    -0
      app/pages/index.vue
  14. +242
    -0
      app/pages/recharge.vue
  15. +160
    -0
      app/pages/works.vue
  16. +107
    -0
      app/utils/md5.js
  17. +40
    -0
      nuxt.config.js
  18. +23
    -0
      package.json
  19. +9392
    -0
      pnpm-lock.yaml
  20. +3
    -0
      pnpm-workspace.yaml
  21. +81
    -0
      server/api/[...].js

+ 24
- 0
.gitignore Näytä tiedosto

@@ -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

+ 126
- 0
README.md Näytä tiedosto

@@ -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**:开发服务器,监听所有网口(支持局域网访问)

+ 7
- 0
app/app.vue Näytä tiedosto

@@ -0,0 +1,7 @@
<template>
<div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>

+ 37
- 0
app/assets/css/main.css Näytä tiedosto

@@ -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;
}

+ 318
- 0
app/components/LoginModal.vue Näytä tiedosto

@@ -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>

+ 150
- 0
app/composables/useApi.js Näytä tiedosto

@@ -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,
}
}

+ 120
- 0
app/composables/useUpload.js Näytä tiedosto

@@ -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,
}
}

+ 122
- 0
app/composables/useUser.js Näytä tiedosto

@@ -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,
}
}

+ 93
- 0
app/layouts/default.vue Näytä tiedosto

@@ -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>&copy; {{ 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>

+ 724
- 0
app/pages/ai-name.vue Näytä tiedosto

@@ -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>

+ 644
- 0
app/pages/anime-avatar.vue Näytä tiedosto

@@ -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>

+ 174
- 0
app/pages/bill.vue Näytä tiedosto

@@ -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>

+ 105
- 0
app/pages/index.vue Näytä tiedosto

@@ -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>

+ 242
- 0
app/pages/recharge.vue Näytä tiedosto

@@ -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>

+ 160
- 0
app/pages/works.vue Näytä tiedosto

@@ -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>

+ 107
- 0
app/utils/md5.js Näytä tiedosto

@@ -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))))
}

+ 40
- 0
nuxt.config.js Näytä tiedosto

@@ -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' }],
},
},
})

+ 23
- 0
package.json Näytä tiedosto

@@ -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"
}
}

+ 9392
- 0
pnpm-lock.yaml
File diff suppressed because it is too large
Näytä tiedosto


+ 3
- 0
pnpm-workspace.yaml Näytä tiedosto

@@ -0,0 +1,3 @@
allowBuilds:
esbuild: set this to true or false
vue-demi: set this to true or false

+ 81
- 0
server/api/[...].js Näytä tiedosto

@@ -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: '服务器代理请求失败',
}
}
})

Ladataan…
Peruuta
Tallenna