| @@ -117,10 +117,33 @@ $fetch('/api/user/info') | |||||
| → http://localhost:8360/user/info(自动附带 cookie) | → http://localhost:8360/user/info(自动附带 cookie) | ||||
| ``` | ``` | ||||
| ## 主题(亮色 / 暗色) | |||||
| 亮色是基准,暗色是同一套结构换一套取值,切换由页头的 `ThemeToggle` 组件完成。 | |||||
| - 颜色令牌集中在 `app/assets/css/theme.css`:`:root` 是亮色(取值与改造前的硬编码色值完全一致),`html.dark` 是暗色。 | |||||
| - 切换基于 `@nuxtjs/color-mode`(`@nuxt/ui` 自带):在 `<html>` 上加 `.dark` 类,偏好写入 cookie `ai-color-mode`;默认亮色、不跟随系统(见 `nuxt.config.js` 的 `colorMode`)。 | |||||
| - **写样式时不要再写死色值**,用令牌: | |||||
| ```html | |||||
| <!-- ✅ --> | |||||
| <div class="bg-[var(--c-ffffff)] text-[var(--c-172033)] border-[var(--c-e8eef7)]"> | |||||
| <!-- ❌ --> | |||||
| <div class="bg-white text-[#172033] border-[#e8eef7]"> | |||||
| ``` | |||||
| 常用令牌:`--c-ffffff` 面板底、`--c-f3f7fb` 页面底、`--c-e8eef7` 边框、`--c-172033` 标题、`--c-4b5b70` 正文、`--c-8a97a8` 弱文字、`--c-0b8cff` 品牌色、`--c-eef7ff` 品牌浅底、`--c-ink` 亮暗一致的深色实底。阴影颜色用 `--sh-*`。 | |||||
| - 如果新页面不小心又写了硬编码色值才,跑一次 codemod 补齐: | |||||
| ```bash | |||||
| node scripts/gen-theme.mjs # 替换源码 + 重新生成 theme.css(可重复执行) | |||||
| node scripts/gen-theme.mjs --dry # 只看影响范围 | |||||
| ``` | |||||
| 脚本按「该色值主要当文字(text)、当描边(border / ring / divide)还是当底色(surface)」决定暗色映射方向 —— 描边必须映射成比面板更亮的线色,否则暗色下边框会和面板同色、直接看不见。关键层级的暗色取值在脚本的 `CURATED` 里人工校准(品牌蓝钉死不变)。 | |||||
| - 阴影:亮色是「浅灰蓝 + 极低透明度」,暗色下换成近黑并提高透明度;卡片边缘主要靠描边和「面板 / 页面底」明度差来区分。`main.css` 里还给「有投影的白底卡片」在暗色下补了一条 `outline` 内描边,补回亮色靠投影撑出的那圈边缘。 | |||||
| - `text-white`、`bg-white/10`~`/30`、`bg-black/*` 这类叠在品牌渐变上的装饰色不参与换算,保持原样。 | |||||
| ## 约定 | ## 约定 | ||||
| - **纯 JS**:不用 TypeScript,`.vue` 文件使用 `<script setup>` | - **纯 JS**:不用 TypeScript,`.vue` 文件使用 `<script setup>` | ||||
| - **SEO**:每个页面必须写 `useSeoMeta()` 设置 title / description | - **SEO**:每个页面必须写 `useSeoMeta()` 设置 title / description | ||||
| - **样式**:统一用 TailwindCSS utility class,避免写裸 CSS | |||||
| - **样式**:统一用 TailwindCSS utility class,避免写裸 CSS;颜色一律走上面的主题令牌 | |||||
| - **就近一致**:复用 composables / 组件,不引入功能重复的新库 | - **就近一致**:复用 composables / 组件,不引入功能重复的新库 | ||||
| - **端口 6888**:开发服务器,监听所有网口(支持局域网访问) | - **端口 6888**:开发服务器,监听所有网口(支持局域网访问) | ||||
| @@ -3,11 +3,23 @@ | |||||
| /* @nuxt/ui v4 内置 TailwindCSS v4,需在入口显式导入 */ | /* @nuxt/ui v4 内置 TailwindCSS v4,需在入口显式导入 */ | ||||
| @import "tailwindcss"; | @import "tailwindcss"; | ||||
| /* 主题色令牌(亮色 / 暗色两套取值,由 scripts/gen-theme.mjs 生成) */ | |||||
| @import "./theme.css"; | |||||
| /* 亮暗两色不变的固定色(避免被语义令牌误映射) */ | |||||
| :root, | |||||
| html.dark { | |||||
| /* 深色实底:证件照页选中态等「深底 + 白字」的组件用它,暗色下同样成立 */ | |||||
| --c-ink: #172033; | |||||
| } | |||||
| /* 全局字体与基础样式 */ | /* 全局字体与基础样式 */ | ||||
| body { | body { | ||||
| font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif; | font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif; | ||||
| -webkit-font-smoothing: antialiased; | -webkit-font-smoothing: antialiased; | ||||
| -moz-osx-font-smoothing: grayscale; | -moz-osx-font-smoothing: grayscale; | ||||
| background-color: var(--c-f3f7fb); | |||||
| color: var(--c-172033); | |||||
| } | } | ||||
| /* 页面过渡动画 */ | /* 页面过渡动画 */ | ||||
| @@ -29,9 +41,21 @@ body { | |||||
| background: transparent; | background: transparent; | ||||
| } | } | ||||
| ::-webkit-scrollbar-thumb { | ::-webkit-scrollbar-thumb { | ||||
| background: #cbd5e1; | |||||
| background: var(--c-cbd5e1); | |||||
| border-radius: 3px; | border-radius: 3px; | ||||
| } | } | ||||
| ::-webkit-scrollbar-thumb:hover { | ::-webkit-scrollbar-thumb:hover { | ||||
| background: #94a3b8; | |||||
| background: var(--c-94a3b8); | |||||
| } | |||||
| /** | |||||
| * 暗色下的卡片边缘。 | |||||
| * | |||||
| * 亮色里「白底 + 浅灰蓝投影」是靠投影撑出层次的,投影在深色底上几乎不可见, | |||||
| * 所以给这类「有投影的白底卡片」补一条内描边(outline 不占布局、跟随圆角)。 | |||||
| * 已经写了 border 的卡片描边同色,视觉上会合并,不会出现双线。 | |||||
| */ | |||||
| html.dark [class*="bg-[var(--c-ffffff)]"][class*="shadow-["] { | |||||
| outline: 1px solid var(--c-e8eef7); | |||||
| outline-offset: -1px; | |||||
| } | } | ||||
| @@ -0,0 +1,514 @@ | |||||
| /** | |||||
| * 主题色令牌(由 scripts/gen-theme.mjs 生成,勿手改生成段) | |||||
| * | |||||
| * :root 为亮色,取值与改造前的硬编码色值完全一致 —— 亮色视觉不变。 | |||||
| * html.dark 为暗色,按层级(页面底/面板/边框/文字/品牌/语义)分桶取值。 | |||||
| * 切换由 @nuxtjs/color-mode(Nuxt UI 内置)在 <html> 上加 .dark 类完成。 | |||||
| */ | |||||
| /* Tailwind v4 默认 dark: 走 prefers-color-scheme,这里改为跟随 .dark 类 */ | |||||
| @custom-variant dark (&:where(.dark, .dark *)); | |||||
| :root { | |||||
| color-scheme: light; | |||||
| --c-004da8: #004da8; | |||||
| --c-0758c9: #0758c9; | |||||
| --c-086fd5: #086fd5; | |||||
| --c-0879dc: #0879dc; | |||||
| --c-087dde: #087dde; | |||||
| --c-087de5: #087de5; | |||||
| --c-096dca: #096dca; | |||||
| --c-0a7ce0: #0a7ce0; | |||||
| --c-0b1324: #0b1324; | |||||
| --c-0b1a2b: #0b1a2b; | |||||
| --c-0b74ea: #0b74ea; | |||||
| --c-0b8cff: #0b8cff; | |||||
| --c-0d1b2f: #0d1b2f; | |||||
| --c-0e1524: #0e1524; | |||||
| --c-0f172a: #0f172a; | |||||
| --c-111e34: #111e34; | |||||
| --c-132238: #132238; | |||||
| --c-14894d: #14894d; | |||||
| --c-159a54: #159a54; | |||||
| --c-16a15d: #16a15d; | |||||
| --c-172033: #172033; | |||||
| --c-17a65a: #17a65a; | |||||
| --c-18a66a: #18a66a; | |||||
| --c-1979e6: #1979e6; | |||||
| --c-19ad72: #19ad72; | |||||
| --c-1f8cff: #1f8cff; | |||||
| --c-20d3a2: #20d3a2; | |||||
| --c-26354b: #26354b; | |||||
| --c-334155: #334155; | |||||
| --c-36b7ff: #36b7ff; | |||||
| --c-39b6ff: #39b6ff; | |||||
| --c-3a5878: #3a5878; | |||||
| --c-3aa9ff: #3aa9ff; | |||||
| --c-3f4c60: #3f4c60; | |||||
| --c-41b9ff: #41b9ff; | |||||
| --c-438edb: #438edb; | |||||
| --c-46698d: #46698d; | |||||
| --c-46c2ff: #46c2ff; | |||||
| --c-475569: #475569; | |||||
| --c-4b5b70: #4b5b70; | |||||
| --c-526174: #526174; | |||||
| --c-52647b: #52647b; | |||||
| --c-54c8ff: #54c8ff; | |||||
| --c-55c8ff: #55c8ff; | |||||
| --c-596579: #596579; | |||||
| --c-5b6b80: #5b6b80; | |||||
| --c-5f6f83: #5f6f83; | |||||
| --c-5f6f84: #5f6f84; | |||||
| --c-5f7087: #5f7087; | |||||
| --c-607087: #607087; | |||||
| --c-64748a: #64748a; | |||||
| --c-64748b: #64748b; | |||||
| --c-66758a: #66758a; | |||||
| --c-6848d8: #6848d8; | |||||
| --c-687386: #687386; | |||||
| --c-6f7f93: #6f7f93; | |||||
| --c-718096: #718096; | |||||
| --c-7253e5: #7253e5; | |||||
| --c-77869a: #77869a; | |||||
| --c-77879b: #77879b; | |||||
| --c-78869a: #78869a; | |||||
| --c-7a5af8: #7a5af8; | |||||
| --c-7b8797: #7b8797; | |||||
| --c-7b899c: #7b899c; | |||||
| --c-7c899b: #7c899b; | |||||
| --c-7f8896: #7f8896; | |||||
| --c-7f8c9f: #7f8c9f; | |||||
| --c-7f8da0: #7f8da0; | |||||
| --c-8190a4: #8190a4; | |||||
| --c-8492a6: #8492a6; | |||||
| --c-8793a5: #8793a5; | |||||
| --c-8794a6: #8794a6; | |||||
| --c-8996a8: #8996a8; | |||||
| --c-8a95a6: #8a95a6; | |||||
| --c-8a95a8: #8a95a8; | |||||
| --c-8a97a8: #8a97a8; | |||||
| --c-8affd8: #8affd8; | |||||
| --c-8b96a8: #8b96a8; | |||||
| --c-94a3b8: #94a3b8; | |||||
| --c-9aa4b2: #9aa4b2; | |||||
| --c-9aa5b4: #9aa5b4; | |||||
| --c-9aa5b5: #9aa5b5; | |||||
| --c-9aa6b5: #9aa6b5; | |||||
| --c-9ba7b7: #9ba7b7; | |||||
| --c-9cc8eb: #9cc8eb; | |||||
| --c-a0a8b8: #a0a8b8; | |||||
| --c-a0aaba: #a0aaba; | |||||
| --c-a0aabb: #a0aabb; | |||||
| --c-a8b2c0: #a8b2c0; | |||||
| --c-b1bbc8: #b1bbc8; | |||||
| --c-b9ddff: #b9ddff; | |||||
| --c-bcdcff: #bcdcff; | |||||
| --c-c3cddb: #c3cddb; | |||||
| --c-c63d42: #c63d42; | |||||
| --c-cbd5e1: #cbd5e1; | |||||
| --c-d2f5e2: #d2f5e2; | |||||
| --c-d7deea: #d7deea; | |||||
| --c-d8f4e5: #d8f4e5; | |||||
| --c-d92d20: #d92d20; | |||||
| --c-d95414: #d95414; | |||||
| --c-d9e3ef: #d9e3ef; | |||||
| --c-d9ecff: #d9ecff; | |||||
| --c-dbe9f7: #dbe9f7; | |||||
| --c-dbeafe: #dbeafe; | |||||
| --c-dbeaff: #dbeaff; | |||||
| --c-dce4ee: #dce4ee; | |||||
| --c-dcecfb: #dcecfb; | |||||
| --c-dceefe: #dceefe; | |||||
| --c-dceeff: #dceeff; | |||||
| --c-ddecfb: #ddecfb; | |||||
| --c-dfe6ef: #dfe6ef; | |||||
| --c-dfe7f0: #dfe7f0; | |||||
| --c-dfe8f2: #dfe8f2; | |||||
| --c-dfe8f4: #dfe8f4; | |||||
| --c-dfefff: #dfefff; | |||||
| --c-e0f0ff: #e0f0ff; | |||||
| --c-e1f1ff: #e1f1ff; | |||||
| --c-e2dafd: #e2dafd; | |||||
| --c-e2e8f0: #e2e8f0; | |||||
| --c-e3eaf2: #e3eaf2; | |||||
| --c-e4ebf4: #e4ebf4; | |||||
| --c-e4edf7: #e4edf7; | |||||
| --c-e5484d: #e5484d; | |||||
| --c-e5e7eb: #e5e7eb; | |||||
| --c-e5edf7: #e5edf7; | |||||
| --c-e6edf5: #e6edf5; | |||||
| --c-e6edf6: #e6edf6; | |||||
| --c-e7edf5: #e7edf5; | |||||
| --c-e7eef7: #e7eef7; | |||||
| --c-e7eef8: #e7eef8; | |||||
| --c-e85f1c: #e85f1c; | |||||
| --c-e8eef6: #e8eef6; | |||||
| --c-e8eef7: #e8eef7; | |||||
| --c-e8f5ff: #e8f5ff; | |||||
| --c-e9eff7: #e9eff7; | |||||
| --c-eaf0f7: #eaf0f7; | |||||
| --c-eaf0f8: #eaf0f8; | |||||
| --c-eaf0ff: #eaf0ff; | |||||
| --c-eaf5ff: #eaf5ff; | |||||
| --c-eaf6ff: #eaf6ff; | |||||
| --c-ecfbf3: #ecfbf3; | |||||
| --c-edf1f6: #edf1f6; | |||||
| --c-edf2f7: #edf2f7; | |||||
| --c-edf2f8: #edf2f8; | |||||
| --c-edf3fb: #edf3fb; | |||||
| --c-edfdf4: #edfdf4; | |||||
| --c-eef1f6: #eef1f6; | |||||
| --c-eef2f7: #eef2f7; | |||||
| --c-eef3f8: #eef3f8; | |||||
| --c-eef6ff: #eef6ff; | |||||
| --c-eef7ff: #eef7ff; | |||||
| --c-eff8ff: #eff8ff; | |||||
| --c-f04438: #f04438; | |||||
| --c-f06b1d: #f06b1d; | |||||
| --c-f06b3c: #f06b3c; | |||||
| --c-f0f8ff: #f0f8ff; | |||||
| --c-f1f5f9: #f1f5f9; | |||||
| --c-f1f5fa: #f1f5fa; | |||||
| --c-f36b21: #f36b21; | |||||
| --c-f3f0ff: #f3f0ff; | |||||
| --c-f3f4f6: #f3f4f6; | |||||
| --c-f3f6fa: #f3f6fa; | |||||
| --c-f3f7fb: #f3f7fb; | |||||
| --c-f3f9ff: #f3f9ff; | |||||
| --c-f3fbf9: #f3fbf9; | |||||
| --c-f4f7fb: #f4f7fb; | |||||
| --c-f4faff: #f4faff; | |||||
| --c-f5f8fc: #f5f8fc; | |||||
| --c-f5f9fd: #f5f9fd; | |||||
| --c-f5faff: #f5faff; | |||||
| --c-f5fbff: #f5fbff; | |||||
| --c-f6f8fb: #f6f8fb; | |||||
| --c-f6f9fc: #f6f9fc; | |||||
| --c-f6f9fd: #f6f9fd; | |||||
| --c-f6f9fe: #f6f9fe; | |||||
| --c-f7f9fc: #f7f9fc; | |||||
| --c-f7f9fd: #f7f9fd; | |||||
| --c-f7faff: #f7faff; | |||||
| --c-f7fbff: #f7fbff; | |||||
| --c-f8fafc: #f8fafc; | |||||
| --c-f8fbfe: #f8fbfe; | |||||
| --c-f8fbff: #f8fbff; | |||||
| --c-f9fafb: #f9fafb; | |||||
| --c-fafbfd: #fafbfd; | |||||
| --c-fbfcfe: #fbfcfe; | |||||
| --c-fbfdff: #fbfdff; | |||||
| --c-ff681f: #ff681f; | |||||
| --c-ff6b6b: #ff6b6b; | |||||
| --c-ff8a00: #ff8a00; | |||||
| --c-ffe0cc: #ffe0cc; | |||||
| --c-fff0ef: #fff0ef; | |||||
| --c-fff1e8: #fff1e8; | |||||
| --c-fff1f0: #fff1f0; | |||||
| --c-fff1f1: #fff1f1; | |||||
| --c-fff2e8: #fff2e8; | |||||
| --c-fff2f1: #fff2f1; | |||||
| --c-fff3f0: #fff3f0; | |||||
| --c-fff4ec: #fff4ec; | |||||
| --c-fff5e8: #fff5e8; | |||||
| --c-fff5f5: #fff5f5; | |||||
| --c-fff9f7: #fff9f7; | |||||
| --c-ffffff: #ffffff; | |||||
| --sh-0-65-140-160: rgba(0, 65, 140, 0.16); | |||||
| --sh-0-92-190-100: rgba(0, 92, 190, 0.1); | |||||
| --sh-11-112-220-240: rgba(11, 112, 220, 0.24); | |||||
| --sh-11-126-225-240: rgba(11, 126, 225, 0.24); | |||||
| --sh-11-140-255-100: rgba(11, 140, 255, 0.1); | |||||
| --sh-11-140-255-120: rgba(11, 140, 255, 0.12); | |||||
| --sh-11-140-255-180: rgba(11, 140, 255, 0.18); | |||||
| --sh-11-140-255-200: rgba(11, 140, 255, 0.2); | |||||
| --sh-11-140-255-220: rgba(11, 140, 255, 0.22); | |||||
| --sh-11-140-255-240: rgba(11, 140, 255, 0.24); | |||||
| --sh-11-140-255-250: rgba(11, 140, 255, 0.25); | |||||
| --sh-11-140-255-280: rgba(11, 140, 255, 0.28); | |||||
| --sh-11-140-255-300: rgba(11, 140, 255, 0.3); | |||||
| --sh-11-140-255-350: rgba(11, 140, 255, 0.35); | |||||
| --sh-11-140-255-360: rgba(11, 140, 255, 0.36); | |||||
| --sh-15-23-42-240: rgba(15, 23, 42, 0.24); | |||||
| --sh-15-23-42-280: rgba(15, 23, 42, 0.28); | |||||
| --sh-20-37-66-220: rgba(20, 37, 66, 0.22); | |||||
| --sh-23-32-51-140: rgba(23, 32, 51, 0.14); | |||||
| --sh-23-32-51-60: rgba(23, 32, 51, 0.06); | |||||
| --sh-23-58-110-100: rgba(23, 58, 110, 0.1); | |||||
| --sh-23-58-110-120: rgba(23, 58, 110, 0.12); | |||||
| --sh-23-58-110-140: rgba(23, 58, 110, 0.14); | |||||
| --sh-23-58-110-160: rgba(23, 58, 110, 0.16); | |||||
| --sh-25-101-170-120: rgba(25, 101, 170, 0.12); | |||||
| --sh-28-69-120-130: rgba(28, 69, 120, 0.13); | |||||
| --sh-31-140-255-200: rgba(31, 140, 255, 0.2); | |||||
| --sh-31-140-255-280: rgba(31, 140, 255, 0.28); | |||||
| --sh-31-62-112-130: rgba(31, 62, 112, 0.13); | |||||
| --sh-31-62-112-160: rgba(31, 62, 112, 0.16); | |||||
| --sh-31-62-112-180: rgba(31, 62, 112, 0.18); | |||||
| --sh-32-56-92-100: rgba(32, 56, 92, 0.1); | |||||
| --sh-32-56-92-50: rgba(32, 56, 92, 0.05); | |||||
| --sh-33-68-112-60: rgba(33, 68, 112, 0.06); | |||||
| --sh-34-68-112-120: rgba(34, 68, 112, 0.12); | |||||
| --sh-34-68-112-140: rgba(34, 68, 112, 0.14); | |||||
| --sh-34-68-112-40: rgba(34, 68, 112, 0.04); | |||||
| --sh-34-68-112-45: rgba(34, 68, 112, 0.045); | |||||
| --sh-34-68-112-50: rgba(34, 68, 112, 0.05); | |||||
| --sh-34-68-112-60: rgba(34, 68, 112, 0.06); | |||||
| --sh-35-55-90-80: rgba(35, 55, 90, 0.08); | |||||
| --sh-35-64-105-60: rgba(35, 64, 105, 0.06); | |||||
| --sh-35-64-105-80: rgba(35, 64, 105, 0.08); | |||||
| --sh-44-104-168-240: rgba(44, 104, 168, 0.24); | |||||
| --sh-8-27-54-280: rgba(8, 27, 54, 0.28); | |||||
| --sh-9-16-28-420: rgba(9, 16, 28, 0.42); | |||||
| } | |||||
| html.dark { | |||||
| color-scheme: dark; | |||||
| --c-004da8: #004da8; | |||||
| --c-0758c9: #0758c9; | |||||
| --c-086fd5: #53a6f9; | |||||
| --c-0879dc: #53abf9; | |||||
| --c-087dde: #087dde; | |||||
| --c-087de5: #087de5; | |||||
| --c-096dca: #096dca; | |||||
| --c-0a7ce0: #0a7ce0; | |||||
| --c-0b1324: #0b1324; | |||||
| --c-0b1a2b: #0b1a2b; | |||||
| --c-0b74ea: #55a1f7; | |||||
| --c-0b8cff: #0b8cff; | |||||
| --c-0d1b2f: #0d1b2f; | |||||
| --c-0e1524: #0e1524; | |||||
| --c-0f172a: #0f172a; | |||||
| --c-111e34: #111e34; | |||||
| --c-132238: #132238; | |||||
| --c-14894d: #63e8a4; | |||||
| --c-159a54: #38c884; | |||||
| --c-16a15d: #62eaa7; | |||||
| --c-172033: #e9eef6; | |||||
| --c-17a65a: #38c884; | |||||
| --c-18a66a: #63e8b0; | |||||
| --c-1979e6: #1979e6; | |||||
| --c-19ad72: #19ad72; | |||||
| --c-1f8cff: #1f8cff; | |||||
| --c-20d3a2: #20d3a2; | |||||
| --c-26354b: #26354b; | |||||
| --c-334155: #dadfe7; | |||||
| --c-36b7ff: #36b7ff; | |||||
| --c-39b6ff: #39b6ff; | |||||
| --c-3a5878: #dae0e7; | |||||
| --c-3aa9ff: #3aa9ff; | |||||
| --c-3f4c60: #dadfe7; | |||||
| --c-41b9ff: #41b9ff; | |||||
| --c-438edb: #438edb; | |||||
| --c-46698d: #dae0e7; | |||||
| --c-46c2ff: #46c2ff; | |||||
| --c-475569: #dadfe6; | |||||
| --c-4b5b70: #aebdd0; | |||||
| --c-526174: #dbe0e6; | |||||
| --c-52647b: #dae0e7; | |||||
| --c-54c8ff: #54c8ff; | |||||
| --c-55c8ff: #55c8ff; | |||||
| --c-596579: #dcdfe5; | |||||
| --c-5b6b80: #a2b2c6; | |||||
| --c-5f6f83: #dce0e5; | |||||
| --c-5f6f84: #dbe0e5; | |||||
| --c-5f7087: #8d9caf; | |||||
| --c-607087: #8e9bae; | |||||
| --c-64748a: #8f9cae; | |||||
| --c-64748b: #8e9bae; | |||||
| --c-66758a: #97a7bb; | |||||
| --c-6848d8: #866ce0; | |||||
| --c-687386: #929baa; | |||||
| --c-6f7f93: #909dac; | |||||
| --c-718096: #909bad; | |||||
| --c-7253e5: #8064e8; | |||||
| --c-77869a: #909cac; | |||||
| --c-77879b: #8f9cad; | |||||
| --c-78869a: #909cac; | |||||
| --c-7a5af8: #a68cff; | |||||
| --c-7b8797: #939caa; | |||||
| --c-7b899c: #909cac; | |||||
| --c-7c899b: #919cab; | |||||
| --c-7f8896: #959ca8; | |||||
| --c-7f8c9f: #909cac; | |||||
| --c-7f8da0: #909cac; | |||||
| --c-8190a4: #8e9cae; | |||||
| --c-8492a6: #8f9bae; | |||||
| --c-8793a5: #909bac; | |||||
| --c-8794a6: #909cac; | |||||
| --c-8996a8: #8f9cad; | |||||
| --c-8a95a6: #8494a8; | |||||
| --c-8a95a8: #909aac; | |||||
| --c-8a97a8: #8494a8; | |||||
| --c-8affd8: #8affd8; | |||||
| --c-8b96a8: #909bac; | |||||
| --c-94a3b8: #8b9bb2; | |||||
| --c-9aa4b2: #919cab; | |||||
| --c-9aa5b4: #909cac; | |||||
| --c-9aa5b5: #7c8b9f; | |||||
| --c-9aa6b5: #8f9cad; | |||||
| --c-9ba7b7: #8e9cae; | |||||
| --c-9cc8eb: #244660; | |||||
| --c-a0a8b8: #9099ac; | |||||
| --c-a0aaba: #8f9bad; | |||||
| --c-a0aabb: #7c8b9f; | |||||
| --c-a8b2c0: #8f9cae; | |||||
| --c-b1bbc8: #8d9caf; | |||||
| --c-b9ddff: #1c344a; | |||||
| --c-bcdcff: #46607c; | |||||
| --c-c3cddb: #495d79; | |||||
| --c-c63d42: #d67579; | |||||
| --c-cbd5e1: #314154; | |||||
| --c-d2f5e2: #467c5f; | |||||
| --c-d7deea: #232f43; | |||||
| --c-d8f4e5: #406450; | |||||
| --c-d92d20: #e86d63; | |||||
| --c-d95414: #f08c5c; | |||||
| --c-d9e3ef: #465e7c; | |||||
| --c-d9ecff: #405264; | |||||
| --c-dbe9f7: #405264; | |||||
| --c-dbeafe: #404f64; | |||||
| --c-dbeaff: #404f64; | |||||
| --c-dce4ee: #465e7c; | |||||
| --c-dcecfb: #405264; | |||||
| --c-dceefe: #405364; | |||||
| --c-dceeff: #18293a; | |||||
| --c-ddecfb: #405264; | |||||
| --c-dfe6ef: #404f64; | |||||
| --c-dfe7f0: #213245; | |||||
| --c-dfe8f2: #405164; | |||||
| --c-dfe8f4: #1c304a; | |||||
| --c-dfefff: #18293a; | |||||
| --c-e0f0ff: #1b3550; | |||||
| --c-e1f1ff: #182a3a; | |||||
| --c-e2dafd: #484064; | |||||
| --c-e2e8f0: #2f3a49; | |||||
| --c-e3eaf2: #405064; | |||||
| --c-e4ebf4: #1e3048; | |||||
| --c-e4edf7: #405164; | |||||
| --c-e5484d: #ff6f63; | |||||
| --c-e5e7eb: #2f3a49; | |||||
| --c-e5edf7: #405064; | |||||
| --c-e6edf5: #18283a; | |||||
| --c-e6edf6: #404f64; | |||||
| --c-e7edf5: #2f3a49; | |||||
| --c-e7eef7: #404f64; | |||||
| --c-e7eef8: #18263a; | |||||
| --c-e85f1c: #ff9455; | |||||
| --c-e8eef6: #2f3a49; | |||||
| --c-e8eef7: #2f3a49; | |||||
| --c-e8f5ff: #182b3a; | |||||
| --c-e9eff7: #2f3a49; | |||||
| --c-eaf0f7: #202939; | |||||
| --c-eaf0f8: #202939; | |||||
| --c-eaf0ff: #8694b6; | |||||
| --c-eaf5ff: #182a3a; | |||||
| --c-eaf6ff: #182b3a; | |||||
| --c-ecfbf3: #183a28; | |||||
| --c-edf1f6: #2f3a49; | |||||
| --c-edf2f7: #2f3a49; | |||||
| --c-edf2f8: #202939; | |||||
| --c-edf3fb: #1b2331; | |||||
| --c-edfdf4: #183a27; | |||||
| --c-eef1f6: #2f3a49; | |||||
| --c-eef2f7: #2f3a49; | |||||
| --c-eef3f8: #202939; | |||||
| --c-eef6ff: #18283a; | |||||
| --c-eef7ff: #152a40; | |||||
| --c-eff8ff: #182b3a; | |||||
| --c-f04438: #ff6f63; | |||||
| --c-f06b1d: #f49158; | |||||
| --c-f06b3c: #f28159; | |||||
| --c-f0f8ff: #182a3a; | |||||
| --c-f1f5f9: #1b2331; | |||||
| --c-f1f5fa: #1d2632; | |||||
| --c-f36b21: #f68e56; | |||||
| --c-f3f0ff: #1f183a; | |||||
| --c-f3f4f6: #1b2331; | |||||
| --c-f3f6fa: #1b2331; | |||||
| --c-f3f7fb: #090d13; | |||||
| --c-f3f9ff: #1b2331; | |||||
| --c-f3fbf9: #1b2331; | |||||
| --c-f4f7fb: #090d13; | |||||
| --c-f4faff: #1b2331; | |||||
| --c-f5f8fc: #1b2331; | |||||
| --c-f5f9fd: #1b2331; | |||||
| --c-f5faff: #1b2331; | |||||
| --c-f5fbff: #1b2331; | |||||
| --c-f6f8fb: #1b2331; | |||||
| --c-f6f9fc: #1b2331; | |||||
| --c-f6f9fd: #1b2331; | |||||
| --c-f6f9fe: #1b2331; | |||||
| --c-f7f9fc: #1b2331; | |||||
| --c-f7f9fd: #1d2632; | |||||
| --c-f7faff: #1b2331; | |||||
| --c-f7fbff: #1b2331; | |||||
| --c-f8fafc: #1b2331; | |||||
| --c-f8fbfe: #1b2331; | |||||
| --c-f8fbff: #1b2331; | |||||
| --c-f9fafb: #1b2331; | |||||
| --c-fafbfd: #2f3a49; | |||||
| --c-fbfcfe: #171e2a; | |||||
| --c-fbfdff: #171e2a; | |||||
| --c-ff681f: #ff9455; | |||||
| --c-ff6b6b: #ff6b6b; | |||||
| --c-ff8a00: #fbad51; | |||||
| --c-ffe0cc: #644e40; | |||||
| --c-fff0ef: #3a1a18; | |||||
| --c-fff1e8: #3a2518; | |||||
| --c-fff1f0: #3a1a18; | |||||
| --c-fff1f1: #1b2331; | |||||
| --c-fff2e8: #3a2718; | |||||
| --c-fff2f1: #1b2331; | |||||
| --c-fff3f0: #3a1f18; | |||||
| --c-fff4ec: #3a2618; | |||||
| --c-fff5e8: #3a2b18; | |||||
| --c-fff5f5: #1b2331; | |||||
| --c-fff9f7: #2f3a49; | |||||
| --c-ffffff: #171e2a; | |||||
| --sh-0-65-140-160: rgba(0, 0, 0, 0.512); | |||||
| --sh-0-92-190-100: rgba(0, 92, 190, 0.13); | |||||
| --sh-11-112-220-240: rgba(11, 112, 220, 0.312); | |||||
| --sh-11-126-225-240: rgba(11, 126, 225, 0.312); | |||||
| --sh-11-140-255-100: rgba(11, 140, 255, 0.13); | |||||
| --sh-11-140-255-120: rgba(11, 140, 255, 0.156); | |||||
| --sh-11-140-255-180: rgba(11, 140, 255, 0.234); | |||||
| --sh-11-140-255-200: rgba(11, 140, 255, 0.26); | |||||
| --sh-11-140-255-220: rgba(11, 140, 255, 0.286); | |||||
| --sh-11-140-255-240: rgba(11, 140, 255, 0.312); | |||||
| --sh-11-140-255-250: rgba(11, 140, 255, 0.325); | |||||
| --sh-11-140-255-280: rgba(11, 140, 255, 0.364); | |||||
| --sh-11-140-255-300: rgba(11, 140, 255, 0.39); | |||||
| --sh-11-140-255-350: rgba(11, 140, 255, 0.455); | |||||
| --sh-11-140-255-360: rgba(11, 140, 255, 0.468); | |||||
| --sh-15-23-42-240: rgba(0, 0, 0, 0.62); | |||||
| --sh-15-23-42-280: rgba(0, 0, 0, 0.62); | |||||
| --sh-20-37-66-220: rgba(0, 0, 0, 0.62); | |||||
| --sh-23-32-51-140: rgba(0, 0, 0, 0.448); | |||||
| --sh-23-32-51-60: rgba(0, 0, 0, 0.28); | |||||
| --sh-23-58-110-100: rgba(0, 0, 0, 0.32); | |||||
| --sh-23-58-110-120: rgba(0, 0, 0, 0.384); | |||||
| --sh-23-58-110-140: rgba(0, 0, 0, 0.448); | |||||
| --sh-23-58-110-160: rgba(0, 0, 0, 0.512); | |||||
| --sh-25-101-170-120: rgba(0, 0, 0, 0.384); | |||||
| --sh-28-69-120-130: rgba(0, 0, 0, 0.416); | |||||
| --sh-31-140-255-200: rgba(31, 140, 255, 0.26); | |||||
| --sh-31-140-255-280: rgba(31, 140, 255, 0.364); | |||||
| --sh-31-62-112-130: rgba(0, 0, 0, 0.416); | |||||
| --sh-31-62-112-160: rgba(0, 0, 0, 0.512); | |||||
| --sh-31-62-112-180: rgba(0, 0, 0, 0.576); | |||||
| --sh-32-56-92-100: rgba(0, 0, 0, 0.32); | |||||
| --sh-32-56-92-50: rgba(0, 0, 0, 0.28); | |||||
| --sh-33-68-112-60: rgba(0, 0, 0, 0.28); | |||||
| --sh-34-68-112-120: rgba(0, 0, 0, 0.384); | |||||
| --sh-34-68-112-140: rgba(0, 0, 0, 0.448); | |||||
| --sh-34-68-112-40: rgba(0, 0, 0, 0.28); | |||||
| --sh-34-68-112-45: rgba(0, 0, 0, 0.28); | |||||
| --sh-34-68-112-50: rgba(0, 0, 0, 0.28); | |||||
| --sh-34-68-112-60: rgba(0, 0, 0, 0.28); | |||||
| --sh-35-55-90-80: rgba(0, 0, 0, 0.28); | |||||
| --sh-35-64-105-60: rgba(0, 0, 0, 0.28); | |||||
| --sh-35-64-105-80: rgba(0, 0, 0, 0.28); | |||||
| --sh-44-104-168-240: rgba(0, 0, 0, 0.62); | |||||
| --sh-8-27-54-280: rgba(0, 0, 0, 0.62); | |||||
| --sh-9-16-28-420: rgba(0, 0, 0, 0.62); | |||||
| } | |||||
| @@ -0,0 +1,45 @@ | |||||
| <template> | |||||
| <!-- | |||||
| 统一的页面 Hero 区域。 | |||||
| 目的:各工具页 hero 高度一致、底色一致(PC 端多页切换时不会突兀)。 | |||||
| 左侧文案 + 右侧自定义插槽(预览卡片等)。 | |||||
| --> | |||||
| <section class="relative overflow-hidden border-b border-[var(--c-e8eef7)] bg-gradient-to-br from-[var(--c-ffffff)] via-[var(--c-eef6ff)] to-[var(--c-f5faff)]"> | |||||
| <!-- 装饰光斑(低饱和,避免 PC 端色块过重) --> | |||||
| <div class="pointer-events-none absolute -right-24 -top-28 h-72 w-72 rounded-full bg-[var(--c-0b8cff)]/10 blur-2xl" /> | |||||
| <div class="pointer-events-none absolute -bottom-28 right-40 h-56 w-56 rounded-full bg-[var(--c-20d3a2)]/10 blur-2xl" /> | |||||
| <div class="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> | |||||
| <div class="flex min-h-[220px] flex-col justify-center gap-6 py-8 lg:min-h-[280px] lg:flex-row lg:items-center lg:justify-between lg:gap-10 lg:py-10"> | |||||
| <!-- 文案 --> | |||||
| <div class="max-w-[560px]"> | |||||
| <div v-if="kicker" class="text-[11px] font-extrabold tracking-[0.18em] text-[var(--c-0b8cff)] lg:text-xs"> | |||||
| {{ kicker }} | |||||
| </div> | |||||
| <h1 class="mt-3 text-[26px] font-extrabold leading-tight text-[var(--c-172033)] lg:text-[36px]"> | |||||
| {{ title }} | |||||
| </h1> | |||||
| <p v-if="desc" class="mt-3 text-sm leading-relaxed text-[var(--c-66758a)] lg:text-base"> | |||||
| {{ desc }} | |||||
| </p> | |||||
| <div v-if="$slots.actions" class="mt-5 flex flex-wrap items-center gap-3"> | |||||
| <slot name="actions" /> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 右侧插槽:预览卡片等 --> | |||||
| <div v-if="$slots.aside" class="flex-shrink-0"> | |||||
| <slot name="aside" /> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </section> | |||||
| </template> | |||||
| <script setup> | |||||
| defineProps({ | |||||
| kicker: { type: String, default: '' }, | |||||
| title: { type: String, required: true }, | |||||
| desc: { type: String, default: '' }, | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,191 @@ | |||||
| <template> | |||||
| <div> | |||||
| <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-10"> | |||||
| <div class="lg:grid lg:grid-cols-12 lg:gap-8"> | |||||
| <!-- 正文 --> | |||||
| <div class="lg:col-span-8"> | |||||
| <article class="overflow-hidden rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_8px_28px_var(--sh-34-68-112-50)] sm:p-8"> | |||||
| <!-- 面包屑 --> | |||||
| <nav aria-label="面包屑" class="flex flex-wrap items-center gap-1.5 text-xs text-[var(--c-8a97a8)]"> | |||||
| <NuxtLink to="/" class="hover:text-[var(--c-0b8cff)]">首页</NuxtLink> | |||||
| <span>/</span> | |||||
| <NuxtLink to="/info" class="hover:text-[var(--c-0b8cff)]">AI 资讯</NuxtLink> | |||||
| <span>/</span> | |||||
| <NuxtLink :to="colPath(detail.col_id)" class="hover:text-[var(--c-0b8cff)]">{{ colName(detail.col_id) }}</NuxtLink> | |||||
| </nav> | |||||
| <h1 class="mt-4 text-2xl font-extrabold leading-snug text-[var(--c-172033)] sm:text-3xl"> | |||||
| {{ detail.title }} | |||||
| </h1> | |||||
| <!-- 元信息 --> | |||||
| <div class="mt-4 flex flex-wrap items-center gap-4 border-b border-[var(--c-eef2f7)] pb-5 text-xs text-[var(--c-a0aabb)]"> | |||||
| <span class="inline-flex items-center gap-1"> | |||||
| <UIcon name="i-lucide-calendar" class="size-3.5" /> | |||||
| {{ formatTime(detail.publish_time) }} | |||||
| </span> | |||||
| <span class="inline-flex items-center gap-1"> | |||||
| <UIcon name="i-lucide-eye" class="size-3.5" /> | |||||
| {{ detail.view_num || 0 }} 次浏览 | |||||
| </span> | |||||
| <span class="rounded-full bg-[var(--c-eef7ff)] px-2 py-0.5 font-bold text-[var(--c-0b8cff)]"> | |||||
| {{ colName(detail.col_id) }} | |||||
| </span> | |||||
| </div> | |||||
| <!-- 摘要 --> | |||||
| <p v-if="detail.info" class="mt-5 rounded-xl bg-[var(--c-f7f9fd)] p-4 text-sm leading-relaxed text-[var(--c-4b5b70)]"> | |||||
| {{ detail.info }} | |||||
| </p> | |||||
| <!-- 正文富文本 --> | |||||
| <!-- eslint-disable-next-line vue/no-v-html --> | |||||
| <div class="article-content mt-6" v-html="detail.content" /> | |||||
| <!-- 标签 --> | |||||
| <div v-if="detail.tag_list?.length" class="mt-8 flex flex-wrap items-center gap-2 border-t border-[var(--c-eef2f7)] pt-6"> | |||||
| <span class="text-xs font-bold text-[var(--c-8a97a8)]">标签:</span> | |||||
| <NuxtLink | |||||
| v-for="tag in detail.tag_list" | |||||
| :key="tag.id" | |||||
| :to="`/info/tag/${tag.tag_key}.html`" | |||||
| class="rounded-full bg-[var(--c-f7f9fd)] px-3 py-1.5 text-xs font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]" | |||||
| > | |||||
| {{ tag.name }} | |||||
| </NuxtLink> | |||||
| </div> | |||||
| <!-- 上一篇 / 下一篇 --> | |||||
| <div v-if="detail.prev_id || detail.next_id" class="mt-8 grid gap-3 border-t border-[var(--c-eef2f7)] pt-6 sm:grid-cols-2"> | |||||
| <NuxtLink | |||||
| v-if="detail.prev_id" | |||||
| :to="`${colPath(detail.col_id)}/${detail.prev_id}.html`" | |||||
| rel="prev" | |||||
| class="group rounded-xl bg-[var(--c-f7f9fd)] p-4 transition-colors hover:bg-[var(--c-eef7ff)]" | |||||
| > | |||||
| <div class="text-xs text-[var(--c-a0aabb)]">上一篇</div> | |||||
| <div class="mt-1 text-sm font-bold text-[var(--c-4b5b70)] transition-colors group-hover:text-[var(--c-0b8cff)]">查看上一篇</div> | |||||
| </NuxtLink> | |||||
| <NuxtLink | |||||
| v-if="detail.next_id" | |||||
| :to="`${colPath(detail.col_id)}/${detail.next_id}.html`" | |||||
| rel="next" | |||||
| class="group rounded-xl bg-[var(--c-f7f9fd)] p-4 text-right transition-colors hover:bg-[var(--c-eef7ff)] sm:col-start-2" | |||||
| > | |||||
| <div class="text-xs text-[var(--c-a0aabb)]">下一篇</div> | |||||
| <div class="mt-1 text-sm font-bold text-[var(--c-4b5b70)] transition-colors group-hover:text-[var(--c-0b8cff)]">查看下一篇</div> | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </article> | |||||
| </div> | |||||
| <!-- 侧栏 --> | |||||
| <div class="mt-8 lg:col-span-4 lg:mt-0"> | |||||
| <div class="lg:sticky lg:top-20"> | |||||
| <NewsSidebar :hot-list="hotList" :tag-list="tagList" /> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| import { colName, colPath, formatTime } from '~/composables/useArticle' | |||||
| defineProps({ | |||||
| detail: { type: Object, required: true }, | |||||
| hotList: { type: Array, default: () => [] }, | |||||
| tagList: { type: Array, default: () => [] }, | |||||
| }) | |||||
| </script> | |||||
| <style scoped> | |||||
| /* 富文本正文排版(后台编辑器输出的 HTML) */ | |||||
| .article-content { | |||||
| font-size: 15px; | |||||
| line-height: 1.9; | |||||
| color: var(--c-3f4c60); | |||||
| word-break: break-word; | |||||
| } | |||||
| .article-content :deep(h1), | |||||
| .article-content :deep(h2), | |||||
| .article-content :deep(h3), | |||||
| .article-content :deep(h4) { | |||||
| margin: 1.6em 0 0.7em; | |||||
| font-weight: 800; | |||||
| color: var(--c-172033); | |||||
| line-height: 1.4; | |||||
| } | |||||
| .article-content :deep(h1) { font-size: 1.5em; } | |||||
| .article-content :deep(h2) { font-size: 1.3em; } | |||||
| .article-content :deep(h3) { font-size: 1.15em; } | |||||
| .article-content :deep(p) { margin: 0.9em 0; } | |||||
| .article-content :deep(img) { | |||||
| max-width: 100%; | |||||
| height: auto; | |||||
| margin: 1.2em auto; | |||||
| display: block; | |||||
| border-radius: 12px; | |||||
| } | |||||
| .article-content :deep(a) { | |||||
| color: var(--c-0b8cff); | |||||
| text-decoration: underline; | |||||
| } | |||||
| .article-content :deep(ul), | |||||
| .article-content :deep(ol) { | |||||
| margin: 0.9em 0; | |||||
| padding-left: 1.4em; | |||||
| } | |||||
| .article-content :deep(ul) { list-style: disc; } | |||||
| .article-content :deep(ol) { list-style: decimal; } | |||||
| .article-content :deep(li) { margin: 0.4em 0; } | |||||
| .article-content :deep(blockquote) { | |||||
| margin: 1.2em 0; | |||||
| padding: 12px 16px; | |||||
| border-left: 4px solid var(--c-0b8cff); | |||||
| border-radius: 0 10px 10px 0; | |||||
| background: var(--c-f7fbff); | |||||
| color: var(--c-4b5b70); | |||||
| } | |||||
| .article-content :deep(pre) { | |||||
| margin: 1.2em 0; | |||||
| padding: 14px 16px; | |||||
| overflow-x: auto; | |||||
| border-radius: 12px; | |||||
| background: var(--c-0e1524); | |||||
| color: var(--c-eaf0ff); | |||||
| font-size: 13px; | |||||
| line-height: 1.7; | |||||
| } | |||||
| .article-content :deep(code) { | |||||
| padding: 2px 6px; | |||||
| border-radius: 5px; | |||||
| background: var(--c-f1f5fa); | |||||
| font-size: 0.92em; | |||||
| } | |||||
| .article-content :deep(pre code) { | |||||
| padding: 0; | |||||
| background: transparent; | |||||
| } | |||||
| .article-content :deep(table) { | |||||
| width: 100%; | |||||
| margin: 1.2em 0; | |||||
| border-collapse: collapse; | |||||
| font-size: 14px; | |||||
| } | |||||
| .article-content :deep(th), | |||||
| .article-content :deep(td) { | |||||
| border: 1px solid var(--c-e8eef7); | |||||
| padding: 8px 12px; | |||||
| } | |||||
| .article-content :deep(th) { | |||||
| background: var(--c-f7f9fd); | |||||
| font-weight: 700; | |||||
| } | |||||
| .article-content :deep(hr) { | |||||
| margin: 1.6em 0; | |||||
| border: 0; | |||||
| border-top: 1px solid var(--c-eef2f7); | |||||
| } | |||||
| </style> | |||||
| @@ -0,0 +1,174 @@ | |||||
| <template> | |||||
| <div> | |||||
| <!-- Hero --> | |||||
| <section class="border-b border-[var(--c-e8eef7)] bg-gradient-to-br from-[var(--c-ffffff)] via-[var(--c-eef6ff)] to-[var(--c-f5faff)]"> | |||||
| <div class="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8 lg:py-14"> | |||||
| <!-- 面包屑(SEO) --> | |||||
| <nav aria-label="面包屑" class="flex items-center gap-1.5 text-xs text-[var(--c-8a97a8)]"> | |||||
| <NuxtLink to="/" class="hover:text-[var(--c-0b8cff)]">首页</NuxtLink> | |||||
| <span>/</span> | |||||
| <NuxtLink v-if="colId || tagName" to="/info" class="hover:text-[var(--c-0b8cff)]">AI 资讯</NuxtLink> | |||||
| <span v-if="colId || tagName">/</span> | |||||
| <span class="text-[var(--c-4b5b70)]">{{ heading }}</span> | |||||
| </nav> | |||||
| <h1 class="mt-4 text-3xl font-extrabold text-[var(--c-172033)] sm:text-4xl">{{ heading }}</h1> | |||||
| <p class="mt-3 max-w-2xl text-sm leading-relaxed text-[var(--c-66758a)] sm:text-base">{{ subtitle }}</p> | |||||
| <!-- 栏目切换 --> | |||||
| <div class="mt-6 flex flex-wrap gap-2"> | |||||
| <NuxtLink | |||||
| to="/info" | |||||
| class="rounded-full px-4 py-2 text-sm font-bold transition-colors" | |||||
| :class="!colId && !tagName ? 'bg-[var(--c-0b8cff)] text-white shadow-[0_8px_18px_var(--sh-11-140-255-220)]' : 'bg-[var(--c-ffffff)] text-[var(--c-5b6b80)] hover:text-[var(--c-0b8cff)]'" | |||||
| > | |||||
| 全部 | |||||
| </NuxtLink> | |||||
| <NuxtLink | |||||
| v-for="col in COLUMNS" | |||||
| :key="col.colId" | |||||
| :to="col.path" | |||||
| class="inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-sm font-bold transition-colors" | |||||
| :class="colId === col.colId ? 'bg-[var(--c-0b8cff)] text-white shadow-[0_8px_18px_var(--sh-11-140-255-220)]' : 'bg-[var(--c-ffffff)] text-[var(--c-5b6b80)] hover:text-[var(--c-0b8cff)]'" | |||||
| > | |||||
| <UIcon :name="col.icon" class="size-4" /> | |||||
| {{ col.name }} | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </div> | |||||
| </section> | |||||
| <!-- 内容 --> | |||||
| <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 lg:py-12"> | |||||
| <div class="lg:grid lg:grid-cols-12 lg:gap-8"> | |||||
| <!-- 列表 --> | |||||
| <div class="lg:col-span-8"> | |||||
| <div v-if="pending" class="space-y-4"> | |||||
| <div v-for="i in 5" :key="i" class="h-32 animate-pulse rounded-2xl bg-[var(--c-ffffff)]" /> | |||||
| </div> | |||||
| <div v-else-if="list.length" class="space-y-4"> | |||||
| <article | |||||
| v-for="item in list" | |||||
| :key="item.id" | |||||
| class="group overflow-hidden rounded-2xl bg-[var(--c-ffffff)] shadow-[0_8px_28px_var(--sh-34-68-112-50)] transition-all hover:shadow-[0_14px_34px_var(--sh-34-68-112-120)]" | |||||
| > | |||||
| <NuxtLink :to="item.url" class="flex flex-col gap-4 p-4 sm:flex-row sm:p-5"> | |||||
| <!-- 封面 --> | |||||
| <div v-if="item.cover" class="shrink-0 overflow-hidden rounded-xl bg-[var(--c-eef3f8)] sm:w-52"> | |||||
| <img | |||||
| :src="item.cover" | |||||
| :alt="item.title" | |||||
| class="h-40 w-full object-cover transition-transform duration-500 group-hover:scale-105 sm:h-32" | |||||
| loading="lazy" | |||||
| > | |||||
| </div> | |||||
| <div class="flex min-w-0 flex-1 flex-col"> | |||||
| <h2 class="line-clamp-2 text-base font-extrabold leading-snug text-[var(--c-172033)] transition-colors group-hover:text-[var(--c-0b8cff)] sm:text-lg"> | |||||
| {{ item.title }} | |||||
| </h2> | |||||
| <p v-if="item.info" class="mt-2 line-clamp-2 flex-1 text-sm leading-relaxed text-[var(--c-8a97a8)]"> | |||||
| {{ item.info }} | |||||
| </p> | |||||
| <div class="mt-3 flex flex-wrap items-center gap-3 text-xs text-[var(--c-a0aabb)]"> | |||||
| <span class="rounded-full bg-[var(--c-eef7ff)] px-2 py-0.5 font-bold text-[var(--c-0b8cff)]"> | |||||
| {{ colName(item.col_id) }} | |||||
| </span> | |||||
| <span class="inline-flex items-center gap-1"> | |||||
| <UIcon name="i-lucide-clock" class="size-3.5" /> | |||||
| {{ item.time }} | |||||
| </span> | |||||
| <span class="inline-flex items-center gap-1"> | |||||
| <UIcon name="i-lucide-eye" class="size-3.5" /> | |||||
| {{ item.view_num || 0 }} | |||||
| </span> | |||||
| </div> | |||||
| </div> | |||||
| </NuxtLink> | |||||
| </article> | |||||
| </div> | |||||
| <!-- 空态 --> | |||||
| <div v-else class="rounded-2xl bg-[var(--c-ffffff)] py-20 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]"> | |||||
| <UIcon name="i-lucide-newspaper" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" /> | |||||
| <div class="text-base font-extrabold text-[var(--c-172033)]">暂无内容</div> | |||||
| <div class="mt-2 text-sm text-[var(--c-8a95a6)]">内容正在整理中,敬请期待</div> | |||||
| </div> | |||||
| <!-- 分页(保留原站 URL 形式,利于 SEO 收录) --> | |||||
| <nav v-if="totalPages > 1" aria-label="分页" class="mt-8 flex flex-wrap items-center justify-center gap-2"> | |||||
| <NuxtLink | |||||
| v-if="page > 1" | |||||
| :to="pageUrl(basePath, page - 1, tagKey)" | |||||
| class="rounded-lg bg-[var(--c-ffffff)] px-3.5 py-2 text-sm font-bold text-[var(--c-5b6b80)] shadow-sm transition-colors hover:text-[var(--c-0b8cff)]" | |||||
| rel="prev" | |||||
| > | |||||
| 上一页 | |||||
| </NuxtLink> | |||||
| <NuxtLink | |||||
| v-for="p in pageNumbers" | |||||
| :key="p" | |||||
| :to="pageUrl(basePath, p, tagKey)" | |||||
| class="min-w-9 rounded-lg px-3 py-2 text-center text-sm font-bold transition-colors" | |||||
| :class="p === page ? 'bg-[var(--c-0b8cff)] text-white' : 'bg-[var(--c-ffffff)] text-[var(--c-5b6b80)] shadow-sm hover:text-[var(--c-0b8cff)]'" | |||||
| > | |||||
| {{ p }} | |||||
| </NuxtLink> | |||||
| <NuxtLink | |||||
| v-if="page < totalPages" | |||||
| :to="pageUrl(basePath, page + 1, tagKey)" | |||||
| class="rounded-lg bg-[var(--c-ffffff)] px-3.5 py-2 text-sm font-bold text-[var(--c-5b6b80)] shadow-sm transition-colors hover:text-[var(--c-0b8cff)]" | |||||
| rel="next" | |||||
| > | |||||
| 下一页 | |||||
| </NuxtLink> | |||||
| </nav> | |||||
| </div> | |||||
| <!-- 侧栏 --> | |||||
| <div class="mt-8 lg:col-span-4 lg:mt-0"> | |||||
| <div class="lg:sticky lg:top-20"> | |||||
| <NewsSidebar :hot-list="hotList" :tag-list="tagList" :active-tag="tagKey" /> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| import { COLUMNS, colName, pageUrl } from '~/composables/useArticle' | |||||
| const props = defineProps({ | |||||
| /** 栏目 id,不传表示「全部」 */ | |||||
| colId: { type: Number, default: 0 }, | |||||
| /** 标签 key(标签页使用) */ | |||||
| tagKey: { type: String, default: '' }, | |||||
| /** 列表基础路径,用于生成分页链接 */ | |||||
| basePath: { type: String, default: '/info' }, | |||||
| heading: { type: String, required: true }, | |||||
| subtitle: { type: String, default: '' }, | |||||
| page: { type: Number, default: 1 }, | |||||
| pageSize: { type: Number, default: 10 }, | |||||
| list: { type: Array, default: () => [] }, | |||||
| total: { type: Number, default: 0 }, | |||||
| tagName: { type: String, default: '' }, | |||||
| hotList: { type: Array, default: () => [] }, | |||||
| tagList: { type: Array, default: () => [] }, | |||||
| pending: { type: Boolean, default: false }, | |||||
| }) | |||||
| const totalPages = computed(() => Math.ceil(props.total / props.pageSize) || 0) | |||||
| // 分页只展示当前页附近的页码,避免页码过多 | |||||
| const pageNumbers = computed(() => { | |||||
| const total = totalPages.value | |||||
| const cur = props.page | |||||
| const arr = [] | |||||
| const start = Math.max(1, cur - 2) | |||||
| const end = Math.min(total, start + 4) | |||||
| for (let i = start; i <= end; i++) arr.push(i) | |||||
| return arr | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,126 @@ | |||||
| <template> | |||||
| <Teleport to="body"> | |||||
| <Transition name="b2t"> | |||||
| <button | |||||
| v-if="visible" | |||||
| type="button" | |||||
| aria-label="回到顶部" | |||||
| title="回到顶部" | |||||
| class="b2t-btn" | |||||
| @click="toTop" | |||||
| > | |||||
| <UIcon name="i-lucide-arrow-up" class="size-5" /> | |||||
| </button> | |||||
| </Transition> | |||||
| </Teleport> | |||||
| </template> | |||||
| <script setup> | |||||
| /** | |||||
| * 全局「回到顶部」按钮。 | |||||
| * 挂在 layouts/default.vue 里,所有页面共用一份,不需要各页面单独引入。 | |||||
| * | |||||
| * 几个取舍: | |||||
| * - 滚动超过一屏(600px)才出现,避免短页面上多一个悬浮元素; | |||||
| * - z-index 取 90,压在内容之上、但低于登录弹窗(120)、详情弹窗(120)、历史抽屉(110)、对比弹层(100), | |||||
| * 弹层打开时不会盖在上面; | |||||
| * - 移动端工具页底部有固定操作条(z-50,高约 100px),所以小屏下按钮上移,避免互相遮挡; | |||||
| * - 系统开启「减少动效」时直接跳转,不做平滑滚动。 | |||||
| */ | |||||
| const SHOW_OFFSET = 600 | |||||
| const visible = ref(false) | |||||
| let ticking = false | |||||
| function onScroll() { | |||||
| // 滚动事件用 rAF 节流,避免每帧都读 scrollY 触发重排 | |||||
| if (ticking) return | |||||
| ticking = true | |||||
| requestAnimationFrame(() => { | |||||
| visible.value = window.scrollY > SHOW_OFFSET | |||||
| ticking = false | |||||
| }) | |||||
| } | |||||
| function prefersReducedMotion() { | |||||
| return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches | |||||
| } | |||||
| function toTop() { | |||||
| window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }) | |||||
| } | |||||
| onMounted(() => { | |||||
| onScroll() | |||||
| window.addEventListener('scroll', onScroll, { passive: true }) | |||||
| }) | |||||
| onUnmounted(() => window.removeEventListener('scroll', onScroll)) | |||||
| </script> | |||||
| <style scoped> | |||||
| /* | |||||
| 用 CSS 变量而不是 Tailwind 类,保证 Teleport 到 body 后仍带上 scoped 属性选择器时样式生效, | |||||
| 颜色全部走主题变量,暗色模式自动适配。 | |||||
| */ | |||||
| .b2t-btn { | |||||
| position: fixed; | |||||
| right: 24px; | |||||
| bottom: 112px; | |||||
| z-index: 90; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| width: 44px; | |||||
| height: 44px; | |||||
| border: 1px solid var(--c-e8eef7); | |||||
| border-radius: 9999px; | |||||
| color: var(--c-5b6b80); | |||||
| background: var(--c-ffffff); | |||||
| box-shadow: 0 8px 24px var(--sh-23-32-51-140); | |||||
| cursor: pointer; | |||||
| transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease; | |||||
| } | |||||
| /* 桌面端底部没有固定操作条,可以贴近底部 */ | |||||
| @media (min-width: 1024px) { | |||||
| .b2t-btn { | |||||
| right: 32px; | |||||
| bottom: 32px; | |||||
| width: 48px; | |||||
| height: 48px; | |||||
| } | |||||
| } | |||||
| .b2t-btn:hover { | |||||
| color: var(--c-0b8cff); | |||||
| border-color: var(--c-0b8cff); | |||||
| background: var(--c-eef7ff); | |||||
| } | |||||
| .b2t-btn:focus-visible { | |||||
| outline: 2px solid var(--c-0b8cff); | |||||
| outline-offset: 2px; | |||||
| } | |||||
| .b2t-enter-active, | |||||
| .b2t-leave-active { | |||||
| transition: opacity 0.2s ease, transform 0.2s ease; | |||||
| } | |||||
| .b2t-enter-from, | |||||
| .b2t-leave-to { | |||||
| opacity: 0; | |||||
| transform: translateY(8px); | |||||
| } | |||||
| @media (prefers-reduced-motion: reduce) { | |||||
| .b2t-btn, | |||||
| .b2t-enter-active, | |||||
| .b2t-leave-active { | |||||
| transition: none; | |||||
| } | |||||
| .b2t-enter-from, | |||||
| .b2t-leave-to { | |||||
| transform: none; | |||||
| } | |||||
| } | |||||
| </style> | |||||
| @@ -0,0 +1,47 @@ | |||||
| <template> | |||||
| <!-- | |||||
| 工具页 Hero 右侧的「前后对比」示例卡片。 | |||||
| 动漫头像 / 老照片 / 形象照三个页面共用这一个组件,保证切页时示例卡片位置和尺寸完全重叠 | |||||
| (尺寸:190×142,lg 下 200×150;hero 内边距 py-10 = 80px,加 200px 与其它页 hero 等高)。 | |||||
| --> | |||||
| <div | |||||
| class="cursor-pointer lg:w-[200px]" | |||||
| role="button" | |||||
| :aria-label="`查看${afterLabel}效果对比`" | |||||
| tabindex="0" | |||||
| @click="emit('open')" | |||||
| @keydown.enter.prevent="emit('open')" | |||||
| @keydown.space.prevent="emit('open')" | |||||
| > | |||||
| <div class="relative mx-auto h-[190px] w-[142px] overflow-hidden rounded-[28px] bg-[var(--c-dfe8f4)] shadow-[0_18px_38px_var(--sh-31-62-112-180)] lg:h-[200px] lg:w-[150px]"> | |||||
| <!-- | |||||
| 两张图必须落在同一个盒子里、用同一套 object-fit 参数,再用 clip-path 裁掉原图右半边, | |||||
| 这样左右两半才是同一处画面的前后对照(写法与大图对比弹层一致)。 | |||||
| 早先把原图塞进半宽容器 + 固定像素宽度,两张图的缩放基准不同,人物就会错位。 | |||||
| --> | |||||
| <img :src="after" class="absolute inset-0 h-full w-full object-cover" :alt="`${afterLabel}示例`"> | |||||
| <img | |||||
| :src="before" | |||||
| class="absolute inset-0 h-full w-full object-cover" | |||||
| style="clip-path: inset(0 50% 0 0)" | |||||
| :alt="`${beforeLabel}示例`" | |||||
| > | |||||
| <div class="absolute bottom-0 left-1/2 top-0 -ml-px w-0.5 bg-[var(--c-ffffff)]/95 shadow-[0_0_12px_var(--sh-20-37-66-220)]" /> | |||||
| <div class="absolute left-3 top-3 rounded-full bg-[var(--c-0f172a)]/50 px-2 py-0.5 text-[10px] font-bold text-white">{{ beforeLabel }}</div> | |||||
| <div class="absolute right-3 top-3 rounded-full bg-[var(--c-0f172a)]/50 px-2 py-0.5 text-[10px] font-bold text-white">{{ afterLabel }}</div> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| defineProps({ | |||||
| /** 原图 */ | |||||
| before: { type: String, required: true }, | |||||
| /** 效果图 */ | |||||
| after: { type: String, required: true }, | |||||
| beforeLabel: { type: String, default: '原图' }, | |||||
| /** 效果图角标:动漫 / 修复 / 艺术 */ | |||||
| afterLabel: { type: String, default: '效果' }, | |||||
| }) | |||||
| const emit = defineEmits(['open']) | |||||
| </script> | |||||
| @@ -0,0 +1,112 @@ | |||||
| <template> | |||||
| <Teleport to="body"> | |||||
| <Transition name="invite-modal"> | |||||
| <div | |||||
| v-if="open" | |||||
| class="fixed inset-0 z-[1000] flex items-end justify-center bg-[var(--c-0b1a2b)]/45 p-0 backdrop-blur-sm sm:items-center sm:p-5" | |||||
| role="dialog" | |||||
| aria-modal="true" | |||||
| aria-labelledby="invite-share-title" | |||||
| @click.self="$emit('close')" | |||||
| > | |||||
| <div class="relative w-full rounded-t-3xl bg-[var(--c-ffffff)] px-5 pb-[calc(24px+env(safe-area-inset-bottom))] pt-5 shadow-2xl sm:max-w-md sm:rounded-3xl sm:p-6"> | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <div> | |||||
| <h2 id="invite-share-title" class="text-lg font-extrabold text-[var(--c-172033)]">微信扫码接受邀请</h2> | |||||
| <p class="mt-1 text-xs leading-5 text-[var(--c-7b8797)]">好友使用微信扫描小程序码,即可进入邀请落地页。</p> | |||||
| </div> | |||||
| <button | |||||
| type="button" | |||||
| aria-label="关闭" | |||||
| class="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-[var(--c-f3f6fa)] text-[var(--c-66758a)] transition-colors hover:bg-[var(--c-e8eef7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]" | |||||
| @click="$emit('close')" | |||||
| > | |||||
| <UIcon name="i-lucide-x" class="size-4.5" /> | |||||
| </button> | |||||
| </div> | |||||
| <div class="mx-auto mt-5 flex aspect-square w-full max-w-[260px] items-center justify-center rounded-3xl border border-[var(--c-e8eef7)] bg-[var(--c-f8fbff)] p-4"> | |||||
| <div v-if="loading" class="flex flex-col items-center gap-3 text-xs font-bold text-[var(--c-8a97a8)]" role="status"> | |||||
| <span class="size-7 animate-spin rounded-full border-[3px] border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)] motion-reduce:animate-none" /> | |||||
| 正在生成小程序码 | |||||
| </div> | |||||
| <img | |||||
| v-else-if="qrcodeUrl" | |||||
| :src="qrcodeUrl" | |||||
| alt="邀请好友微信小程序码" | |||||
| class="size-full rounded-2xl bg-[var(--c-ffffff)] object-contain" | |||||
| > | |||||
| <div v-else class="text-center"> | |||||
| <UIcon name="i-lucide-qr-code" class="mx-auto size-9 text-[var(--c-a0aabb)]" /> | |||||
| <p class="mt-2 text-xs text-[var(--c-8a97a8)]">小程序码生成失败,请稍后重试</p> | |||||
| </div> | |||||
| </div> | |||||
| <div class="mt-5 rounded-2xl bg-[var(--c-f6f9fd)] p-3"> | |||||
| <div class="text-[11px] font-bold text-[var(--c-8a97a8)]">网页邀请链接</div> | |||||
| <div class="mt-1.5 truncate text-xs text-[var(--c-4b5b70)]">{{ inviteUrl }}</div> | |||||
| </div> | |||||
| <div class="mt-4 grid grid-cols-2 gap-3"> | |||||
| <button | |||||
| type="button" | |||||
| class="flex h-11 cursor-pointer items-center justify-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2" | |||||
| @click="$emit('copy')" | |||||
| > | |||||
| <UIcon name="i-lucide-copy" class="size-4" /> | |||||
| 复制链接 | |||||
| </button> | |||||
| <a | |||||
| v-if="qrcodeUrl" | |||||
| :href="qrcodeUrl" | |||||
| target="_blank" | |||||
| rel="noopener" | |||||
| class="flex h-11 items-center justify-center gap-1.5 rounded-full bg-[var(--c-0b8cff)] text-sm font-bold text-white transition-colors hover:bg-[var(--c-087dde)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2" | |||||
| > | |||||
| <UIcon name="i-lucide-maximize-2" class="size-4" /> | |||||
| 查看大图 | |||||
| </a> | |||||
| <button | |||||
| v-else | |||||
| type="button" | |||||
| disabled | |||||
| class="flex h-11 cursor-not-allowed items-center justify-center rounded-full bg-[var(--c-e7edf5)] text-sm font-bold text-[var(--c-9aa5b5)]" | |||||
| > | |||||
| 查看大图 | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </Transition> | |||||
| </Teleport> | |||||
| </template> | |||||
| <script setup> | |||||
| defineProps({ | |||||
| open: { type: Boolean, default: false }, | |||||
| qrcodeUrl: { type: String, default: '' }, | |||||
| inviteUrl: { type: String, default: '' }, | |||||
| loading: { type: Boolean, default: false }, | |||||
| }) | |||||
| const emit = defineEmits(['close', 'copy']) | |||||
| function onKeydown(event) { | |||||
| if (event.key === 'Escape') emit('close') | |||||
| } | |||||
| onMounted(() => document.addEventListener('keydown', onKeydown)) | |||||
| onUnmounted(() => document.removeEventListener('keydown', onKeydown)) | |||||
| </script> | |||||
| <style scoped> | |||||
| .invite-modal-enter-active, | |||||
| .invite-modal-leave-active { | |||||
| transition: opacity 0.2s ease; | |||||
| } | |||||
| .invite-modal-enter-from, | |||||
| .invite-modal-leave-to { | |||||
| opacity: 0; | |||||
| } | |||||
| </style> | |||||
| @@ -0,0 +1,124 @@ | |||||
| <template> | |||||
| <div class="min-h-screen bg-[var(--c-f3f7fb)]"> | |||||
| <AppHero :kicker="kicker" :title="title" :desc="updatedText" /> | |||||
| <main class="mx-auto max-w-4xl px-4 py-7 sm:px-6 lg:py-10"> | |||||
| <article class="rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-6 shadow-[0_16px_40px_var(--sh-34-68-112-60)] lg:p-10"> | |||||
| <!-- 正文由后台维护,服务端渲染,内容缺失时给明确空态而不是白屏 --> | |||||
| <div v-if="content" class="legal-body" v-html="content" /> | |||||
| <div v-else class="py-16 text-center"> | |||||
| <UIcon name="i-lucide-file-text" class="mx-auto mb-4 size-10 text-[var(--c-a0aabb)]" /> | |||||
| <div class="text-base font-extrabold text-[var(--c-172033)]">内容暂未发布</div> | |||||
| <p class="mx-auto mt-2 max-w-md text-sm leading-6 text-[var(--c-8a97a8)]"> | |||||
| 该内容正在整理中,如需了解详情可通过页脚的联系方式与我们沟通。 | |||||
| </p> | |||||
| <NuxtLink | |||||
| to="/" | |||||
| class="mt-6 inline-flex h-10 cursor-pointer items-center rounded-full bg-[var(--c-eef7ff)] px-6 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)]" | |||||
| > | |||||
| 返回首页 | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </article> | |||||
| </main> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| import dayjs from 'dayjs' | |||||
| import { cleanRichText } from '~/utils/richtext' | |||||
| const props = defineProps({ | |||||
| /** html 表里的 key:service / privacy */ | |||||
| docKey: { type: String, required: true }, | |||||
| fallbackTitle: { type: String, default: '' }, | |||||
| kicker: { type: String, default: 'LEGAL' }, | |||||
| }) | |||||
| const { post } = useApi() | |||||
| /* | |||||
| SSR 取正文:协议页要能被搜索引擎抓到。 | |||||
| 清洗放在 transform 里,这样 Nuxt payload 里只带清洗后的正文—— | |||||
| 否则原始 Word 正文(3 万多字、带满内联样式)会被序列化进页面,白白多传一份。 | |||||
| */ | |||||
| const { data } = await useAsyncData( | |||||
| () => `legal-${props.docKey}`, | |||||
| () => post('/common/gethtml', { key: props.docKey }).catch(() => ({ data: {} })), | |||||
| { | |||||
| transform: (res) => { | |||||
| const doc = res?.data || {} | |||||
| return { | |||||
| title: doc.title || '', | |||||
| content: cleanRichText(doc.content || ''), | |||||
| update_time: doc.update_time || '', | |||||
| } | |||||
| }, | |||||
| }, | |||||
| ) | |||||
| const title = computed(() => data.value?.title || props.fallbackTitle) | |||||
| const content = computed(() => data.value?.content || '') | |||||
| const updatedText = computed(() => { | |||||
| const time = data.value?.update_time | |||||
| if (!time) return '' | |||||
| return `最近更新:${dayjs(time).format('YYYY-MM-DD')}` | |||||
| }) | |||||
| </script> | |||||
| <style scoped> | |||||
| /* | |||||
| 正文来自后台富文本,用 :deep 统一排版; | |||||
| 颜色走主题变量,暗色模式下同样可读。 | |||||
| */ | |||||
| .legal-body { | |||||
| color: var(--c-4b5b70); | |||||
| font-size: 15px; | |||||
| line-height: 1.85; | |||||
| word-break: break-word; | |||||
| } | |||||
| .legal-body :deep(h1), | |||||
| .legal-body :deep(h2), | |||||
| .legal-body :deep(h3), | |||||
| .legal-body :deep(h4) { | |||||
| margin: 1.6em 0 0.7em; | |||||
| color: var(--c-172033); | |||||
| font-weight: 800; | |||||
| line-height: 1.4; | |||||
| } | |||||
| .legal-body :deep(h1) { font-size: 22px; } | |||||
| .legal-body :deep(h2) { font-size: 19px; } | |||||
| .legal-body :deep(h3) { font-size: 17px; } | |||||
| .legal-body :deep(h4) { font-size: 15px; } | |||||
| /* Word 导出的正文里 p / div / span 用得很随意,这里统一接管排版 */ | |||||
| .legal-body :deep(p) { margin: 0 0 0.9em; padding: 0; } | |||||
| .legal-body :deep(p:last-child) { margin-bottom: 0; } | |||||
| .legal-body :deep(div) { line-height: 1.85; } | |||||
| .legal-body :deep(span) { font-size: inherit; line-height: inherit; color: inherit; } | |||||
| .legal-body :deep(strong), | |||||
| .legal-body :deep(b) { color: var(--c-172033); font-weight: 700; } | |||||
| /* Tailwind preflight 会清掉列表符号与缩进,这里补回来 */ | |||||
| .legal-body :deep(ul), | |||||
| .legal-body :deep(ol) { margin: 0.9em 0; padding-left: 1.6em; } | |||||
| .legal-body :deep(ul) { list-style: disc outside; } | |||||
| .legal-body :deep(ol) { list-style: decimal outside; } | |||||
| .legal-body :deep(ul ul) { list-style: circle outside; } | |||||
| .legal-body :deep(li) { margin: 0.35em 0; padding: 0; line-height: 1.8; } | |||||
| .legal-body :deep(li > p) { margin: 0; } | |||||
| .legal-body :deep(a) { color: var(--c-0b8cff); text-decoration: underline; } | |||||
| .legal-body :deep(img) { max-width: 100%; height: auto; border-radius: 12px; } | |||||
| .legal-body :deep(table) { | |||||
| width: 100%; | |||||
| margin: 1.2em 0; | |||||
| border-collapse: collapse; | |||||
| font-size: 14px; | |||||
| } | |||||
| .legal-body :deep(th), | |||||
| .legal-body :deep(td) { | |||||
| padding: 8px 10px; | |||||
| border: 1px solid var(--c-e7eef7); | |||||
| text-align: left; | |||||
| } | |||||
| .legal-body :deep(th) { background: var(--c-f7f9fd); color: var(--c-172033); font-weight: 700; } | |||||
| .legal-body :deep(hr) { margin: 1.6em 0; border: 0; border-top: 1px solid var(--c-eef2f7); } | |||||
| </style> | |||||
| @@ -1,13 +1,20 @@ | |||||
| <template> | <template> | ||||
| <div class="fixed inset-0 z-[999] flex items-center justify-center"> | |||||
| <div | |||||
| class="fixed inset-0 z-[999] flex items-center justify-center" | |||||
| role="dialog" | |||||
| aria-modal="true" | |||||
| aria-labelledby="login-modal-title" | |||||
| > | |||||
| <!-- 遮罩 --> | <!-- 遮罩 --> | ||||
| <div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="$emit('close')" /> | <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"> | |||||
| <div class="relative w-full max-w-md mx-4 bg-[var(--c-ffffff)] rounded-2xl shadow-2xl overflow-hidden"> | |||||
| <!-- 关闭按钮 --> | <!-- 关闭按钮 --> | ||||
| <button | <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" | |||||
| type="button" | |||||
| aria-label="关闭登录弹窗" | |||||
| class="absolute top-4 right-4 z-10 w-8 h-8 flex items-center justify-center rounded-full text-[var(--c-94a3b8)] hover:text-[var(--c-475569)] hover:bg-[var(--c-f1f5f9)] transition-colors cursor-pointer" | |||||
| @click="$emit('close')" | @click="$emit('close')" | ||||
| > | > | ||||
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||||
| @@ -17,19 +24,20 @@ | |||||
| <!-- 标题 --> | <!-- 标题 --> | ||||
| <div class="px-8 pt-8 pb-4 text-center"> | <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> | |||||
| <img src="/logo.png" alt="AI在线" class="mx-auto mb-3 h-12 w-12 rounded-xl shadow-[0_6px_16px_var(--sh-11-140-255-220)]"> | |||||
| <h2 id="login-modal-title" class="text-xl font-bold text-[var(--c-172033)]">登录 AI 在线</h2> | |||||
| <p class="mt-1 text-sm text-[var(--c-64748b)]">解放你的生产力</p> | |||||
| </div> | </div> | ||||
| <!-- Tab 切换 --> | <!-- Tab 切换 --> | ||||
| <div class="px-8"> | <div class="px-8"> | ||||
| <div class="flex border-b border-slate-200"> | |||||
| <div class="flex border-b border-[var(--c-e2e8f0)]"> | |||||
| <button | <button | ||||
| :class="[ | :class="[ | ||||
| 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer', | 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer', | ||||
| activeTab === 'qr' | activeTab === 'qr' | ||||
| ? 'text-indigo-600 border-indigo-500' | |||||
| : 'text-slate-400 border-transparent hover:text-slate-600', | |||||
| ? 'text-[var(--c-0b8cff)] border-[var(--c-0b8cff)]' | |||||
| : 'text-[var(--c-94a3b8)] border-transparent hover:text-[var(--c-475569)]', | |||||
| ]" | ]" | ||||
| @click="activeTab = 'qr'" | @click="activeTab = 'qr'" | ||||
| > | > | ||||
| @@ -39,8 +47,8 @@ | |||||
| :class="[ | :class="[ | ||||
| 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer', | 'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer', | ||||
| activeTab === 'phone' | activeTab === 'phone' | ||||
| ? 'text-indigo-600 border-indigo-500' | |||||
| : 'text-slate-400 border-transparent hover:text-slate-600', | |||||
| ? 'text-[var(--c-0b8cff)] border-[var(--c-0b8cff)]' | |||||
| : 'text-[var(--c-94a3b8)] border-transparent hover:text-[var(--c-475569)]', | |||||
| ]" | ]" | ||||
| @click="activeTab = 'phone'" | @click="activeTab = 'phone'" | ||||
| > | > | ||||
| @@ -54,29 +62,47 @@ | |||||
| <div class="flex flex-col items-center"> | <div class="flex flex-col items-center"> | ||||
| <!-- 二维码区域 --> | <!-- 二维码区域 --> | ||||
| <div | <div | ||||
| class="relative w-48 h-48 rounded-xl border border-slate-200 flex items-center justify-center bg-white" | |||||
| class="relative w-48 h-48 rounded-xl border border-[var(--c-e2e8f0)] flex items-center justify-center bg-[var(--c-ffffff)]" | |||||
| > | > | ||||
| <!-- 加载中 --> | <!-- 加载中 --> | ||||
| <div v-if="qrLoading" class="flex flex-col items-center gap-2"> | <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"> | |||||
| <svg class="w-8 h-8 text-[var(--c-0b8cff)] animate-spin" fill="none" viewBox="0 0 24 24"> | |||||
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> | <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" /> | <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> | ||||
| </svg> | </svg> | ||||
| <span class="text-xs text-slate-400">获取二维码...</span> | |||||
| <span class="text-xs text-[var(--c-94a3b8)]">获取二维码...</span> | |||||
| </div> | </div> | ||||
| <!-- 二维码生成失败:首次请求未拿到有效 ticket/url --> | |||||
| <button | |||||
| v-else-if="qrFailed" | |||||
| type="button" | |||||
| class="group flex cursor-pointer flex-col items-center gap-2.5 px-4 text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--sh-11-140-255-180)]" | |||||
| aria-label="二维码生成失败,点击重新生成" | |||||
| @click="refreshQR" | |||||
| > | |||||
| <span class="flex size-11 items-center justify-center rounded-full bg-[var(--c-fff1f0)] text-[var(--c-f04438)]"> | |||||
| <UIcon name="i-lucide-circle-alert" class="size-6" /> | |||||
| </span> | |||||
| <span class="text-sm font-bold text-[var(--c-172033)]">二维码生成失败</span> | |||||
| <span class="text-xs leading-5 text-[var(--c-94a3b8)]">{{ qrErrorMessage }}</span> | |||||
| <span class="text-xs font-bold text-[var(--c-0b8cff)] group-hover:underline">点击重新生成</span> | |||||
| </button> | |||||
| <!-- 二维码过期 --> | <!-- 二维码过期 --> | ||||
| <div | |||||
| <button | |||||
| v-else-if="qrExpired" | v-else-if="qrExpired" | ||||
| class="flex flex-col items-center gap-3 cursor-pointer" | |||||
| type="button" | |||||
| class="flex cursor-pointer flex-col items-center gap-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--sh-11-140-255-180)]" | |||||
| aria-label="二维码已过期,点击刷新" | |||||
| @click="refreshQR" | @click="refreshQR" | ||||
| > | > | ||||
| <svg class="w-10 h-10 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |||||
| <svg class="w-10 h-10 text-[var(--c-cbd5e1)]" 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" /> | <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> | </svg> | ||||
| <span class="text-sm text-slate-400">二维码已过期</span> | |||||
| <span class="text-xs text-indigo-500">点击刷新</span> | |||||
| </div> | |||||
| <span class="text-sm text-[var(--c-94a3b8)]">二维码已过期</span> | |||||
| <span class="text-xs font-bold text-[var(--c-0b8cff)]">点击刷新</span> | |||||
| </button> | |||||
| <!-- 二维码 --> | <!-- 二维码 --> | ||||
| <vue-qr | <vue-qr | ||||
| @@ -89,7 +115,7 @@ | |||||
| /> | /> | ||||
| </div> | </div> | ||||
| <p class="mt-4 text-sm text-slate-500 text-center"> | |||||
| <p class="mt-4 text-sm text-[var(--c-64748b)] text-center"> | |||||
| 请使用微信扫描二维码完成登录 | 请使用微信扫描二维码完成登录 | ||||
| </p> | </p> | ||||
| </div> | </div> | ||||
| @@ -100,35 +126,35 @@ | |||||
| <div class="space-y-4"> | <div class="space-y-4"> | ||||
| <!-- 手机号 --> | <!-- 手机号 --> | ||||
| <div> | <div> | ||||
| <label class="block text-xs font-medium text-slate-600 mb-1.5">手机号</label> | |||||
| <label class="block text-xs font-medium text-[var(--c-475569)] mb-1.5">手机号</label> | |||||
| <input | <input | ||||
| v-model="phoneForm.phone" | v-model="phoneForm.phone" | ||||
| type="tel" | type="tel" | ||||
| maxlength="11" | maxlength="11" | ||||
| placeholder="请输入手机号" | 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" | |||||
| class="w-full px-3 py-2.5 text-sm border border-[var(--c-e2e8f0)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--sh-11-140-255-180)] focus:border-[var(--c-0b8cff)] transition-colors" | |||||
| @input="phoneForm.phone = phoneForm.phone.replace(/\D/g, '')" | @input="phoneForm.phone = phoneForm.phone.replace(/\D/g, '')" | ||||
| /> | /> | ||||
| </div> | </div> | ||||
| <!-- 验证码 --> | <!-- 验证码 --> | ||||
| <div> | <div> | ||||
| <label class="block text-xs font-medium text-slate-600 mb-1.5">验证码</label> | |||||
| <label class="block text-xs font-medium text-[var(--c-475569)] mb-1.5">验证码</label> | |||||
| <div class="flex gap-3"> | <div class="flex gap-3"> | ||||
| <input | <input | ||||
| v-model="phoneForm.code" | v-model="phoneForm.code" | ||||
| type="text" | type="text" | ||||
| maxlength="6" | maxlength="6" | ||||
| placeholder="请输入验证码" | 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" | |||||
| class="flex-1 px-3 py-2.5 text-sm border border-[var(--c-e2e8f0)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--sh-11-140-255-180)] focus:border-[var(--c-0b8cff)] transition-colors" | |||||
| /> | /> | ||||
| <button | <button | ||||
| :disabled="phoneForm.sendingCode || countdown > 0" | :disabled="phoneForm.sendingCode || countdown > 0" | ||||
| :class="[ | :class="[ | ||||
| 'shrink-0 px-4 py-2.5 text-sm font-medium rounded-lg transition-colors cursor-pointer', | 'shrink-0 px-4 py-2.5 text-sm font-medium rounded-lg transition-colors cursor-pointer', | ||||
| countdown > 0 | countdown > 0 | ||||
| ? 'text-slate-400 bg-slate-100 cursor-not-allowed' | |||||
| : 'text-indigo-600 bg-indigo-50 hover:bg-indigo-100', | |||||
| ? 'text-[var(--c-94a3b8)] bg-[var(--c-f1f5f9)] cursor-not-allowed' | |||||
| : 'text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)] hover:bg-[var(--c-e0f0ff)]', | |||||
| ]" | ]" | ||||
| @click="sendCode" | @click="sendCode" | ||||
| > | > | ||||
| @@ -137,13 +163,18 @@ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <!-- 开发环境提示(后端未发短信,直接返回验证码) --> | |||||
| <p v-if="phoneForm.devCode" class="text-xs text-[var(--c-94a3b8)]"> | |||||
| 开发环境验证码:<span class="font-bold text-[var(--c-0b8cff)]">{{ phoneForm.devCode }}</span>(已自动填入) | |||||
| </p> | |||||
| <!-- 错误提示 --> | <!-- 错误提示 --> | ||||
| <p v-if="phoneForm.error" class="text-xs text-red-500">{{ phoneForm.error }}</p> | <p v-if="phoneForm.error" class="text-xs text-red-500">{{ phoneForm.error }}</p> | ||||
| <!-- 登录按钮 --> | <!-- 登录按钮 --> | ||||
| <button | <button | ||||
| :disabled="phoneForm.loading || !phoneForm.phone || !phoneForm.code" | :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" | |||||
| class="w-full py-2.5 text-sm font-semibold text-white bg-[var(--c-0b8cff)] hover:bg-[var(--c-087dde)] disabled:bg-[var(--c-cbd5e1)] disabled:cursor-not-allowed rounded-lg transition-colors cursor-pointer" | |||||
| @click="loginByPhone" | @click="loginByPhone" | ||||
| > | > | ||||
| <span v-if="phoneForm.loading" class="inline-flex items-center gap-2"> | <span v-if="phoneForm.loading" class="inline-flex items-center gap-2"> | ||||
| @@ -160,11 +191,11 @@ | |||||
| <!-- 底部协议 --> | <!-- 底部协议 --> | ||||
| <div class="px-8 pb-6 text-center"> | <div class="px-8 pb-6 text-center"> | ||||
| <p class="text-xs text-slate-400"> | |||||
| <p class="text-xs text-[var(--c-94a3b8)]"> | |||||
| 登录即表示同意 | 登录即表示同意 | ||||
| <a href="#" class="text-indigo-500 hover:underline">《服务条款》</a> | |||||
| <NuxtLink to="/terms" target="_blank" class="text-[var(--c-0b8cff)] hover:underline">《服务条款》</NuxtLink> | |||||
| 和 | 和 | ||||
| <a href="#" class="text-indigo-500 hover:underline">《隐私政策》</a> | |||||
| <NuxtLink to="/privacy" target="_blank" class="text-[var(--c-0b8cff)] hover:underline">《隐私政策》</NuxtLink> | |||||
| </p> | </p> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -178,6 +209,8 @@ const emit = defineEmits(['close', 'login-success']) | |||||
| const { post } = useApi() | const { post } = useApi() | ||||
| const { setLogin } = useUser() | const { setLogin } = useUser() | ||||
| const { inviteCode, withInvite, clearInvite } = useInvite() | |||||
| const toast = useToast() | |||||
| // Tab | // Tab | ||||
| const activeTab = ref('qr') | const activeTab = ref('qr') | ||||
| @@ -185,31 +218,51 @@ const activeTab = ref('qr') | |||||
| // ===== 微信扫码 ===== | // ===== 微信扫码 ===== | ||||
| const qrLoading = ref(true) | const qrLoading = ref(true) | ||||
| const qrExpired = ref(false) | const qrExpired = ref(false) | ||||
| const qrFailed = ref(false) | |||||
| const qrErrorMessage = ref('') | |||||
| const qrData = ref({ ticket: '', url: '' }) | const qrData = ref({ ticket: '', url: '' }) | ||||
| const qrLogo = '' // 可替换为 logo URL | const qrLogo = '' // 可替换为 logo URL | ||||
| let qrTimer = null | let qrTimer = null | ||||
| // 获取二维码 | // 获取二维码 | ||||
| async function refreshQR() { | async function refreshQR() { | ||||
| clearInterval(qrTimer) | |||||
| qrLoading.value = true | qrLoading.value = true | ||||
| qrExpired.value = false | qrExpired.value = false | ||||
| qrFailed.value = false | |||||
| qrErrorMessage.value = '' | |||||
| qrData.value = { ticket: '', url: '' } | |||||
| try { | try { | ||||
| const res = await post('/user/getloginqr') | |||||
| if (res.code === 0 && res.data) { | |||||
| // 邀请码必须在创建二维码 Ticket 时写入,扫码回调才能完成邀请归因。 | |||||
| const res = await post('/user/getloginqr', withInvite()) | |||||
| if (res.code === 0 && res.data?.ticket && res.data?.url) { | |||||
| qrData.value = res.data | qrData.value = res.data | ||||
| qrLoading.value = false | qrLoading.value = false | ||||
| startPolling() | startPolling() | ||||
| } else { | } else { | ||||
| qrLoadError() | qrLoadError() | ||||
| } | } | ||||
| } catch { | |||||
| qrLoadError() | |||||
| } catch (error) { | |||||
| qrLoadError(error) | |||||
| } | } | ||||
| } | } | ||||
| function qrLoadError() { | |||||
| function qrLoadError(error) { | |||||
| clearInterval(qrTimer) | |||||
| qrLoading.value = false | |||||
| qrExpired.value = false | |||||
| qrFailed.value = true | |||||
| const message = String(error?.message || '') | |||||
| qrErrorMessage.value = /access[_ ]?token|登录凭证/i.test(message) | |||||
| ? '未能获取微信登录凭证,请稍后重试' | |||||
| : '暂时无法生成二维码,请稍后重试' | |||||
| } | |||||
| function markQrExpired() { | |||||
| clearInterval(qrTimer) | |||||
| qrLoading.value = false | qrLoading.value = false | ||||
| qrFailed.value = false | |||||
| qrExpired.value = true | qrExpired.value = true | ||||
| } | } | ||||
| @@ -224,11 +277,12 @@ function startPolling() { | |||||
| handleLoginSuccess(res.data) | handleLoginSuccess(res.data) | ||||
| } else if (res.code === 1010) { | } else if (res.code === 1010) { | ||||
| // 二维码过期 | // 二维码过期 | ||||
| clearInterval(qrTimer) | |||||
| qrExpired.value = true | |||||
| markQrExpired() | |||||
| } | } | ||||
| } catch { | |||||
| // 轮询失败不中断 | |||||
| } catch (error) { | |||||
| // useApi 会把非 0 业务码抛出;1010 需要在异常分支识别为真正过期。 | |||||
| if (Number(error?.code) === 1010) markQrExpired() | |||||
| // 其他短暂轮询失败不中断 | |||||
| } | } | ||||
| }, 1000) | }, 1000) | ||||
| } | } | ||||
| @@ -240,6 +294,7 @@ const phoneForm = reactive({ | |||||
| sendingCode: false, | sendingCode: false, | ||||
| loading: false, | loading: false, | ||||
| error: '', | error: '', | ||||
| devCode: '', // 开发环境后端直接返回的验证码,仅用于提示 | |||||
| }) | }) | ||||
| const countdown = ref(0) | const countdown = ref(0) | ||||
| @@ -260,6 +315,11 @@ async function sendCode() { | |||||
| type: 3, | type: 3, | ||||
| }) | }) | ||||
| if (res.code === 0) { | if (res.code === 0) { | ||||
| // 开发/测试环境后端不发短信,直接把验证码返回,这里自动回填方便调试 | |||||
| if (res.data?.code) { | |||||
| phoneForm.code = String(res.data.code) | |||||
| phoneForm.devCode = String(res.data.code) | |||||
| } | |||||
| countdown.value = 60 | countdown.value = 60 | ||||
| countdownTimer = setInterval(() => { | countdownTimer = setInterval(() => { | ||||
| countdown.value-- | countdown.value-- | ||||
| @@ -284,10 +344,10 @@ async function loginByPhone() { | |||||
| phoneForm.loading = true | phoneForm.loading = true | ||||
| try { | try { | ||||
| const res = await post('/user/login_phone', { | |||||
| const res = await post('/user/login_phone', withInvite({ | |||||
| phone: phoneForm.phone, | phone: phoneForm.phone, | ||||
| code: phoneForm.code, | code: phoneForm.code, | ||||
| }) | |||||
| })) | |||||
| if (res.code === 0 && res.data?.token) { | if (res.code === 0 && res.data?.token) { | ||||
| handleLoginSuccess(res.data) | handleLoginSuccess(res.data) | ||||
| } else { | } else { | ||||
| @@ -302,7 +362,14 @@ async function loginByPhone() { | |||||
| // ===== 公共 ===== | // ===== 公共 ===== | ||||
| function handleLoginSuccess(data) { | function handleLoginSuccess(data) { | ||||
| const invited = Boolean(inviteCode.value) | |||||
| setLogin(data) | setLogin(data) | ||||
| clearInvite() | |||||
| toast.add({ | |||||
| title: invited && data.is_register ? '登录并领取成功' : '登录成功', | |||||
| description: invited && data.is_register ? '邀请奖励点数已自动到账。' : '', | |||||
| color: 'success', | |||||
| }) | |||||
| emit('login-success', data) | emit('login-success', data) | ||||
| } | } | ||||
| @@ -0,0 +1,71 @@ | |||||
| <template> | |||||
| <aside class="space-y-5"> | |||||
| <!-- 热门排行 --> | |||||
| <section v-if="hotList.length" class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_8px_28px_var(--sh-34-68-112-50)]"> | |||||
| <h2 class="flex items-center gap-1.5 text-base font-extrabold text-[var(--c-172033)]"> | |||||
| <UIcon name="i-lucide-flame" class="size-4 text-[var(--c-ff681f)]" /> | |||||
| 热门排行 | |||||
| </h2> | |||||
| <ol class="mt-4 space-y-3"> | |||||
| <li v-for="item in hotList" :key="item.id"> | |||||
| <NuxtLink :to="item.url" class="group flex gap-2.5"> | |||||
| <span | |||||
| class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded text-[11px] font-extrabold" | |||||
| :class="item.rank <= 3 ? 'bg-[var(--c-ff681f)] text-white' : 'bg-[var(--c-f1f5fa)] text-[var(--c-8a97a8)]'" | |||||
| >{{ item.rank }}</span> | |||||
| <span class="line-clamp-2 text-sm leading-snug text-[var(--c-4b5b70)] transition-colors group-hover:text-[var(--c-0b8cff)]"> | |||||
| {{ item.title }} | |||||
| </span> | |||||
| </NuxtLink> | |||||
| </li> | |||||
| </ol> | |||||
| </section> | |||||
| <!-- 热门标签 --> | |||||
| <section v-if="tagList.length" class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_8px_28px_var(--sh-34-68-112-50)]"> | |||||
| <h2 class="flex items-center gap-1.5 text-base font-extrabold text-[var(--c-172033)]"> | |||||
| <UIcon name="i-lucide-tags" class="size-4 text-[var(--c-0b8cff)]" /> | |||||
| 热门标签 | |||||
| </h2> | |||||
| <div class="mt-4 flex flex-wrap gap-2"> | |||||
| <NuxtLink | |||||
| v-for="tag in tagList" | |||||
| :key="tag.id" | |||||
| :to="`/info/tag/${tag.key}.html`" | |||||
| class="rounded-full bg-[var(--c-f7f9fd)] px-3 py-1.5 text-xs font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]" | |||||
| :class="{ '!bg-[var(--c-eef7ff)] !text-[var(--c-0b8cff)]': tag.key === activeTag }" | |||||
| > | |||||
| {{ tag.name }} | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </section> | |||||
| <!-- 工具引导(转化位) --> | |||||
| <section class="overflow-hidden rounded-2xl bg-gradient-to-br from-[var(--c-0b8cff)] to-[var(--c-20d3a2)] p-5 text-white"> | |||||
| <h2 class="text-base font-extrabold">试试 AI 创作</h2> | |||||
| <p class="mt-1.5 text-xs leading-relaxed text-white/85">AI 取名、动漫头像,几秒钟出结果。</p> | |||||
| <div class="mt-4 flex flex-col gap-2"> | |||||
| <NuxtLink | |||||
| to="/writing/naming/" | |||||
| class="flex h-9 items-center justify-center rounded-full bg-[var(--c-ffffff)] text-xs font-extrabold text-[var(--c-0b74ea)] transition-opacity hover:opacity-90" | |||||
| > | |||||
| AI 取名 | |||||
| </NuxtLink> | |||||
| <NuxtLink | |||||
| to="/draw/anime/" | |||||
| class="flex h-9 items-center justify-center rounded-full bg-white/20 text-xs font-extrabold text-white transition-colors hover:bg-white/30" | |||||
| > | |||||
| 动漫头像 | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </section> | |||||
| </aside> | |||||
| </template> | |||||
| <script setup> | |||||
| defineProps({ | |||||
| hotList: { type: Array, default: () => [] }, | |||||
| tagList: { type: Array, default: () => [] }, | |||||
| activeTag: { type: String, default: '' }, | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,34 @@ | |||||
| <template> | |||||
| <ClientOnly> | |||||
| <button | |||||
| type="button" | |||||
| class="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)]" | |||||
| :aria-label="isDark ? '切换到亮色模式' : '切换到暗色模式'" | |||||
| :aria-pressed="isDark" | |||||
| :title="isDark ? '亮色模式' : '暗色模式'" | |||||
| @click="toggle" | |||||
| > | |||||
| <UIcon :name="isDark ? 'i-lucide-sun' : 'i-lucide-moon'" class="size-4.5" /> | |||||
| </button> | |||||
| <!-- SSR / 水合前占位,避免图标闪烁 --> | |||||
| <template #fallback> | |||||
| <span class="h-9 w-9 rounded-full bg-[var(--c-eef7ff)]" /> | |||||
| </template> | |||||
| </ClientOnly> | |||||
| </template> | |||||
| <script setup> | |||||
| /** | |||||
| * 亮色 / 暗色切换。 | |||||
| * 状态由 @nuxtjs/color-mode 维护(<html class="dark"> + cookie 持久化), | |||||
| * 具体配色取值见 assets/css/theme.css 里的 CSS 变量。 | |||||
| */ | |||||
| const colorMode = useColorMode() | |||||
| const isDark = computed(() => colorMode.value === 'dark') | |||||
| function toggle() { | |||||
| colorMode.preference = isDark.value ? 'light' : 'dark' | |||||
| } | |||||
| </script> | |||||
| @@ -0,0 +1,521 @@ | |||||
| <template> | |||||
| <Teleport to="body"> | |||||
| <Transition name="wd-modal"> | |||||
| <div v-if="modelValue" class="fixed inset-0 z-[120] flex items-center justify-center overflow-hidden p-3 sm:p-4"> | |||||
| <div class="absolute inset-0 bg-[var(--c-0f172a)]/55 backdrop-blur-sm" @click="close" /> | |||||
| <div class="relative flex max-h-[calc(100dvh-1.5rem)] w-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-[var(--c-ffffff)] shadow-2xl sm:max-h-[88dvh]"> | |||||
| <!-- 头部 --> | |||||
| <div class="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--c-eef2f7)] px-5 py-4"> | |||||
| <div class="min-w-0"> | |||||
| <h3 class="truncate text-base font-extrabold text-[var(--c-172033)]">{{ headerTitle }}</h3> | |||||
| <div v-if="addTimeText" class="mt-0.5 text-xs text-[var(--c-8a97a8)]">{{ addTimeText }}</div> | |||||
| </div> | |||||
| <button | |||||
| class="flex size-8 shrink-0 items-center justify-center rounded-full text-[var(--c-a0aabb)] transition-colors hover:bg-[var(--c-f1f5fa)] hover:text-[var(--c-172033)]" | |||||
| @click="close" | |||||
| > | |||||
| <UIcon name="i-lucide-x" class="size-5" /> | |||||
| </button> | |||||
| </div> | |||||
| <!-- 内容 --> | |||||
| <div class="detail-scroll min-h-[220px] flex-1 overflow-y-auto overscroll-contain px-5 py-5"> | |||||
| <!-- 加载中 --> | |||||
| <div v-if="loading" class="flex flex-col items-center justify-center gap-3 py-16"> | |||||
| <span class="size-8 animate-spin rounded-full border-[3px] border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)]" /> | |||||
| <span class="text-sm text-[var(--c-8a95a6)]">加载详情中...</span> | |||||
| </div> | |||||
| <!-- 出错 --> | |||||
| <div v-else-if="errorMsg" class="py-16 text-center"> | |||||
| <UIcon name="i-lucide-circle-alert" class="mx-auto mb-3 size-10 text-[var(--c-f04438)]" /> | |||||
| <div class="text-sm text-[var(--c-f04438)]">{{ errorMsg }}</div> | |||||
| <button | |||||
| class="mt-4 rounded-full bg-[var(--c-eef7ff)] px-5 py-2 text-xs font-bold text-[var(--c-0b8cff)]" | |||||
| @click="load" | |||||
| > | |||||
| 重试 | |||||
| </button> | |||||
| </div> | |||||
| <template v-else> | |||||
| <!-- ============ AI 取名详情 ============ --> | |||||
| <template v-if="isName"> | |||||
| <div v-if="detail.question_content" class="mb-4 rounded-2xl bg-[var(--c-f7f9fd)] p-4"> | |||||
| <div class="mb-1 text-xs font-bold text-[var(--c-8a97a8)]">取名条件</div> | |||||
| <div class="whitespace-pre-wrap text-sm leading-relaxed text-[var(--c-4b5b70)]">{{ detail.question_content }}</div> | |||||
| </div> | |||||
| <div v-if="nameResult.summary" class="mb-4 rounded-2xl bg-[var(--c-eef7ff)] p-4 text-sm leading-relaxed text-[var(--c-3a5878)]"> | |||||
| {{ nameResult.summary }} | |||||
| </div> | |||||
| <div v-if="nameResult.names.length" class="space-y-3"> | |||||
| <div | |||||
| v-for="(item, idx) in nameResult.names" | |||||
| :key="idx" | |||||
| class="rounded-2xl border border-[var(--c-eef2f7)] p-4" | |||||
| > | |||||
| <div class="mb-2 flex items-center justify-between"> | |||||
| <span class="text-2xl font-extrabold text-[var(--c-0b8cff)]">{{ item.name }}</span> | |||||
| <span v-if="item.score" class="text-base font-extrabold text-[var(--c-ff8a00)]">{{ item.score }}分</span> | |||||
| </div> | |||||
| <div v-if="item.pinyin" class="mb-3 text-xs text-[var(--c-8a97a8)]">{{ item.pinyin }}</div> | |||||
| <div class="space-y-2"> | |||||
| <div v-for="f in NAME_FIELDS" :key="f.key"> | |||||
| <div v-if="item[f.key]" class="flex items-start gap-2.5"> | |||||
| <span class="inline-flex h-[24px] shrink-0 items-center rounded-full bg-[var(--c-eef6ff)] px-2 text-[11px] font-bold text-[var(--c-0b8cff)]"> | |||||
| {{ f.label }} | |||||
| </span> | |||||
| <span class="pt-0.5 text-sm leading-relaxed text-[var(--c-172033)]">{{ item[f.key] }}</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 生成中 / 无结果 --> | |||||
| <div v-else class="py-14 text-center"> | |||||
| <template v-if="isPending"> | |||||
| <span class="mx-auto mb-3 block size-8 animate-spin rounded-full border-[3px] border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)]" /> | |||||
| <div class="text-sm font-bold text-[var(--c-172033)]">名字整理中</div> | |||||
| <div class="mt-1 text-xs text-[var(--c-8a95a6)]">稍后可再次打开查看</div> | |||||
| </template> | |||||
| <template v-else> | |||||
| <UIcon name="i-lucide-search-x" class="mx-auto mb-3 size-10 text-[var(--c-a0aabb)]" /> | |||||
| <div class="text-sm text-[var(--c-8a95a6)]">暂无名字结果</div> | |||||
| </template> | |||||
| </div> | |||||
| </template> | |||||
| <!-- ============ 图片类详情(动漫头像 / 证件照等) ============ --> | |||||
| <template v-else> | |||||
| <div | |||||
| v-if="imageUrl" | |||||
| class="grid min-h-0 gap-3 sm:gap-4" | |||||
| :class="isPhoto ? 'mx-auto w-full max-w-md grid-cols-1' : 'grid-cols-2'" | |||||
| > | |||||
| <!-- 原图 --> | |||||
| <div v-if="originalUrl && !isPhoto" class="min-w-0"> | |||||
| <div class="mb-2 text-xs font-bold text-[var(--c-8a97a8)]">原图</div> | |||||
| <div | |||||
| class="flex max-h-[calc(100dvh-13rem)] items-center justify-center overflow-hidden rounded-2xl bg-[var(--c-f7f9fd)] sm:max-h-[calc(88dvh-13rem)]" | |||||
| :class="{ 'aspect-square': !isPhoto }" | |||||
| :style="imageAspectStyle" | |||||
| > | |||||
| <img :src="originalUrl" class="size-full" :class="isPhoto ? 'object-contain' : 'object-cover'" alt="原图"> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 结果图 --> | |||||
| <div | |||||
| class="min-w-0" | |||||
| :class="{ 'col-span-2 mx-auto w-full max-w-md': !originalUrl && !isPhoto }" | |||||
| > | |||||
| <div class="mb-2 text-xs font-bold text-[var(--c-8a97a8)]">{{ resultLabel }}</div> | |||||
| <div | |||||
| class="flex max-h-[calc(100dvh-13rem)] items-center justify-center overflow-hidden rounded-2xl border-2 border-[var(--c-0b8cff)] bg-[var(--c-f7f9fd)] sm:max-h-[calc(88dvh-13rem)]" | |||||
| :class="{ 'aspect-square': !isPhoto }" | |||||
| :style="imageAspectStyle" | |||||
| > | |||||
| <img :src="imageUrl" class="size-full" :class="isPhoto ? 'object-contain' : 'object-cover'" :alt="resultLabel"> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 形象照一单多张:缩略图切换 --> | |||||
| <div v-if="imageUrl && portraitUrls.length > 1" class="mt-4 flex flex-wrap gap-2.5"> | |||||
| <button | |||||
| v-for="(url, index) in portraitUrls" | |||||
| :key="url" | |||||
| type="button" | |||||
| class="size-14 cursor-pointer overflow-hidden rounded-xl border-2 transition-colors" | |||||
| :class="activePortraitIndex === index ? 'border-[var(--c-0b8cff)]' : 'border-transparent hover:border-[var(--c-e5edf7)]'" | |||||
| :aria-label="`查看第 ${index + 1} 张`" | |||||
| :aria-pressed="activePortraitIndex === index" | |||||
| @click="activePortraitIndex = index" | |||||
| > | |||||
| <img :src="url" class="size-full object-cover" :alt="`第 ${index + 1} 张`"> | |||||
| </button> | |||||
| </div> | |||||
| <div v-if="!imageUrl" class="py-14 text-center"> | |||||
| <template v-if="isPending"> | |||||
| <span class="mx-auto mb-3 block size-8 animate-spin rounded-full border-[3px] border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)]" /> | |||||
| <div class="text-sm font-bold text-[var(--c-172033)]">正在创作</div> | |||||
| <div class="mt-1 text-xs text-[var(--c-8a95a6)]">完成后可在此查看结果</div> | |||||
| </template> | |||||
| <template v-else-if="isFailed"> | |||||
| <UIcon name="i-lucide-circle-alert" class="mx-auto mb-3 size-10 text-[var(--c-f04438)]" /> | |||||
| <div class="text-sm text-[var(--c-f04438)]">{{ detail.err_msg || '生成失败' }}</div> | |||||
| </template> | |||||
| <template v-else> | |||||
| <UIcon name="i-lucide-image-off" class="mx-auto mb-3 size-10 text-[var(--c-a0aabb)]" /> | |||||
| <div class="text-sm text-[var(--c-8a95a6)]">暂无图片</div> | |||||
| </template> | |||||
| </div> | |||||
| </template> | |||||
| </template> | |||||
| </div> | |||||
| <!-- 底部操作 --> | |||||
| <div v-if="!loading && !errorMsg" class="flex shrink-0 flex-wrap justify-end gap-3 border-t border-[var(--c-eef2f7)] px-5 py-4"> | |||||
| <button | |||||
| v-if="canCompare" | |||||
| type="button" | |||||
| class="inline-flex items-center gap-1.5 rounded-full border border-[var(--c-0b8cff)] px-5 py-2.5 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-eef7ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2" | |||||
| @click="openCompare" | |||||
| > | |||||
| <UIcon name="i-lucide-split-square-horizontal" class="size-4" /> | |||||
| 对比效果 | |||||
| </button> | |||||
| <button | |||||
| class="rounded-full border border-[var(--c-e5edf7)] px-5 py-2.5 text-sm font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-f7f9fd)]" | |||||
| @click="close" | |||||
| > | |||||
| 关闭 | |||||
| </button> | |||||
| <button | |||||
| v-if="isName && nameResult.names.length" | |||||
| class="rounded-full bg-[var(--c-0b8cff)] px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-[var(--c-0a7ce0)]" | |||||
| @click="copyNames" | |||||
| > | |||||
| 复制结果 | |||||
| </button> | |||||
| <a | |||||
| v-if="!isName && imageUrl" | |||||
| :href="imageUrl" | |||||
| target="_blank" | |||||
| rel="noopener" | |||||
| download | |||||
| class="rounded-full bg-[var(--c-0b8cff)] px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-[var(--c-0a7ce0)]" | |||||
| > | |||||
| {{ isPhoto ? '下载证件照' : '下载图片' }} | |||||
| </a> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </Transition> | |||||
| <Transition name="wd-modal"> | |||||
| <div v-if="modelValue && compareOpen" class="fixed inset-0 z-[140] flex items-center justify-center overflow-hidden p-3 sm:p-4"> | |||||
| <div class="absolute inset-0 bg-[var(--c-0f172a)]/70 backdrop-blur-sm" @click="closeCompare" /> | |||||
| <div class="relative flex max-h-[calc(100dvh-1.5rem)] w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-[var(--c-ffffff)] shadow-2xl sm:max-h-[90dvh]"> | |||||
| <div class="flex shrink-0 items-center justify-between border-b border-[var(--c-eef2f7)] px-5 py-4"> | |||||
| <div> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)]">{{ compareTitle }}</h3> | |||||
| <p class="mt-0.5 text-xs text-[var(--c-8a97a8)]">左右拖动分割线查看生成前后变化</p> | |||||
| </div> | |||||
| <button | |||||
| type="button" | |||||
| aria-label="关闭对比" | |||||
| class="flex size-8 shrink-0 items-center justify-center rounded-full text-[var(--c-8a97a8)] transition-colors hover:bg-[var(--c-f1f5fa)] hover:text-[var(--c-172033)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]" | |||||
| @click="closeCompare" | |||||
| > | |||||
| <UIcon name="i-lucide-x" class="size-5" /> | |||||
| </button> | |||||
| </div> | |||||
| <div class="min-h-0 flex-1 p-4 sm:p-5"> | |||||
| <div | |||||
| ref="compareSurface" | |||||
| class="relative mx-auto max-h-[calc(100dvh-8.5rem)] w-full max-w-[min(100%,640px)] touch-none select-none overflow-hidden rounded-2xl bg-[var(--c-e8eef6)] cursor-ew-resize sm:max-h-[calc(90dvh-8.5rem)]" | |||||
| :class="{ 'aspect-square': !isPhoto }" | |||||
| :style="imageAspectStyle" | |||||
| @pointerdown="startCompareDrag" | |||||
| > | |||||
| <img :src="imageUrl" class="pointer-events-none absolute inset-0 size-full" :class="isPhoto ? 'object-contain' : 'object-cover'" :alt="resultLabel"> | |||||
| <img | |||||
| :src="originalUrl" | |||||
| class="pointer-events-none absolute inset-0 size-full" | |||||
| :class="isPhoto ? 'object-contain' : 'object-cover'" | |||||
| :style="{ clipPath: `inset(0 ${100 - comparePercent}% 0 0)` }" | |||||
| alt="原图" | |||||
| > | |||||
| <div | |||||
| class="pointer-events-none absolute bottom-0 top-0 w-1 -ml-px bg-[var(--c-ffffff)] shadow-[0_0_14px_var(--sh-15-23-42-280)]" | |||||
| :style="{ left: `${comparePercent}%` }" | |||||
| > | |||||
| <div class="absolute left-1/2 top-1/2 flex h-14 w-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-[var(--c-ffffff)] text-lg font-extrabold text-[var(--c-0b8cff)] shadow-[0_8px_22px_var(--sh-15-23-42-240)]"> | |||||
| <span>‹</span><span>›</span> | |||||
| </div> | |||||
| </div> | |||||
| <span class="pointer-events-none absolute left-3 top-3 rounded-full bg-[var(--c-0f172a)]/55 px-2.5 py-1 text-[11px] font-bold text-white">原图</span> | |||||
| <span class="pointer-events-none absolute right-3 top-3 rounded-full bg-[var(--c-0f172a)]/55 px-2.5 py-1 text-[11px] font-bold text-white">{{ resultLabel }}</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </Transition> | |||||
| </Teleport> | |||||
| </template> | |||||
| <script setup> | |||||
| import dayjs from 'dayjs' | |||||
| import { parseNameResult, buildNameCopyText, NAME_FIELDS } from '~/utils/nameParse' | |||||
| const props = defineProps({ | |||||
| modelValue: { type: Boolean, default: false }, | |||||
| /** 作品项:需含 work_type 与 chat_open_id(或 id) */ | |||||
| work: { type: Object, default: () => ({}) }, | |||||
| }) | |||||
| const emit = defineEmits(['update:modelValue']) | |||||
| const { post } = useApi() | |||||
| const toast = useToast() | |||||
| const loading = ref(false) | |||||
| const errorMsg = ref('') | |||||
| const detail = ref({}) | |||||
| const compareOpen = ref(false) | |||||
| const comparePercent = ref(50) | |||||
| const compareSurface = ref(null) | |||||
| let compareDragging = false | |||||
| const isName = computed(() => props.work?.work_type === 'ainame') | |||||
| const isPhoto = computed(() => props.work?.work_type === 'photo') | |||||
| const isOldPhoto = computed(() => props.work?.work_type === 'oldphoto') | |||||
| const isPortrait = computed(() => props.work?.work_type === 'portrait') | |||||
| /** | |||||
| * 状态归一。 | |||||
| * 老照片与形象照任务用的是自己的一套状态(10排队 20处理中 30成功 -10失败 -20审核不通过), | |||||
| * 这里换算成作品列表通用的 0生成中 / 1完成 / -1失败,避免详情加载完还一直显示「生成中」。 | |||||
| */ | |||||
| const statusValue = computed(() => { | |||||
| if ((isOldPhoto.value || isPortrait.value) && detail.value.status !== undefined) { | |||||
| const status = Number(detail.value.status) | |||||
| if (status === 30) return 1 | |||||
| return status < 0 ? -1 : 0 | |||||
| } | |||||
| return Number(detail.value.is_suc ?? props.work?.status ?? 0) | |||||
| }) | |||||
| const isPending = computed(() => statusValue.value === 0) | |||||
| const isFailed = computed(() => statusValue.value < 0) | |||||
| const resultLabel = computed(() => { | |||||
| if (isPhoto.value) return '证件照' | |||||
| if (isOldPhoto.value) return '修复结果' | |||||
| return '生成结果' | |||||
| }) | |||||
| const compareTitle = computed(() => { | |||||
| if (isPhoto.value) return '证件照效果对比' | |||||
| if (isOldPhoto.value) return '老照片修复对比' | |||||
| if (isPortrait.value) return '形象照效果对比' | |||||
| return '动漫头像效果对比' | |||||
| }) | |||||
| /** 形象照一单可能有多张图,详情里支持切换,切换后对比与下载都跟着走 */ | |||||
| const portraitUrls = computed(() => { | |||||
| if (!isPortrait.value) return [] | |||||
| const urls = detail.value.result_urls || props.work?.pic_list || [] | |||||
| return Array.isArray(urls) ? urls.filter(Boolean) : [] | |||||
| }) | |||||
| const activePortraitIndex = ref(0) | |||||
| const activePortraitUrl = computed( | |||||
| () => portraitUrls.value[activePortraitIndex.value] || portraitUrls.value[0] || '', | |||||
| ) | |||||
| const imageAspectStyle = computed(() => { | |||||
| if (!isPhoto.value) return {} | |||||
| const width = Number(detail.value.width || props.work?.width || 0) | |||||
| const height = Number(detail.value.height || props.work?.height || 0) | |||||
| return width > 0 && height > 0 | |||||
| ? { aspectRatio: `${width} / ${height}` } | |||||
| : { aspectRatio: '5 / 7' } | |||||
| }) | |||||
| const headerTitle = computed(() => { | |||||
| const map = { | |||||
| ainame: 'AI 取名详情', | |||||
| anime: '动漫头像详情', | |||||
| photo: '证件照详情', | |||||
| oldphoto: '老照片详情', | |||||
| portrait: '形象照详情', | |||||
| image: '作品详情', | |||||
| } | |||||
| return map[props.work?.work_type] || '作品详情' | |||||
| }) | |||||
| const addTimeText = computed(() => { | |||||
| const t = detail.value.add_time || props.work?.add_time | |||||
| return t ? dayjs(Number(t) * 1000).format('YYYY-MM-DD HH:mm') : '' | |||||
| }) | |||||
| const nameResult = computed(() => { | |||||
| if (!isName.value) return { summary: '', names: [] } | |||||
| // 优先用后端结构化的 detail,其次解析 content 文本 | |||||
| const d = detail.value.detail | |||||
| if (d && Array.isArray(d.names) && d.names.length) { | |||||
| return { summary: d.summary || '', names: d.names } | |||||
| } | |||||
| return parseNameResult(detail.value.content || '') | |||||
| }) | |||||
| const imageUrl = computed(() => | |||||
| (isPortrait.value ? activePortraitUrl.value : '') | |||||
| || detail.value.answer | |||||
| || detail.value.result_url | |||||
| || detail.value.url | |||||
| || (!isName.value ? detail.value.content : '') | |||||
| || props.work?.cover | |||||
| || (!isName.value ? props.work?.content : '') | |||||
| || '') | |||||
| const originalUrl = computed(() => { | |||||
| const imgs = detail.value.original | |||||
| || detail.value.original_url | |||||
| || detail.value.original_imgs | |||||
| || detail.value.imgs | |||||
| || detail.value.img | |||||
| || props.work?.original | |||||
| || props.work?.original_url | |||||
| || props.work?.imgs | |||||
| || props.work?.img | |||||
| || '' | |||||
| if (Array.isArray(imgs)) return imgs[0] || '' | |||||
| return typeof imgs === 'string' && imgs ? imgs.split(',')[0] : '' | |||||
| }) | |||||
| const canCompare = computed(() => | |||||
| !isName.value | |||||
| && !isPhoto.value | |||||
| && Boolean(originalUrl.value && imageUrl.value) | |||||
| && !isPending.value | |||||
| && !isFailed.value) | |||||
| async function load() { | |||||
| const id = props.work?.photo_open_id || props.work?.chat_open_id || props.work?.id | |||||
| if (!id) { | |||||
| errorMsg.value = '缺少作品标识' | |||||
| return | |||||
| } | |||||
| loading.value = true | |||||
| errorMsg.value = '' | |||||
| try { | |||||
| let path = isName.value ? '/ainame/getdetail' : '/anime/getdetail' | |||||
| let body = { chat_open_id: id } | |||||
| if (isPhoto.value) { | |||||
| path = '/idphoto/getdetail' | |||||
| body = { photo_open_id: id } | |||||
| } | |||||
| if (isOldPhoto.value) { | |||||
| path = '/oldphoto/getdetail' | |||||
| body = { photo_open_id: id } | |||||
| } | |||||
| if (isPortrait.value) { | |||||
| path = '/portrait/getdetail' | |||||
| body = { photo_open_id: id } | |||||
| } | |||||
| const res = await post(path, body) | |||||
| detail.value = res.data || {} | |||||
| // 还在处理中就继续拉:老照片是异步任务,弹窗里也要能等到结果 | |||||
| schedulePendingReload() | |||||
| } catch (e) { | |||||
| errorMsg.value = e.message || '详情加载失败' | |||||
| } finally { | |||||
| loading.value = false | |||||
| } | |||||
| } | |||||
| let pendingTimer = null | |||||
| function schedulePendingReload() { | |||||
| clearPendingReload() | |||||
| if (!props.modelValue || !isPending.value) return | |||||
| pendingTimer = setTimeout(() => load(), 3000) | |||||
| } | |||||
| function clearPendingReload() { | |||||
| if (pendingTimer) clearTimeout(pendingTimer) | |||||
| pendingTimer = null | |||||
| } | |||||
| onUnmounted(() => clearPendingReload()) | |||||
| function close() { | |||||
| closeCompare() | |||||
| clearPendingReload() | |||||
| emit('update:modelValue', false) | |||||
| } | |||||
| function openCompare() { | |||||
| if (!canCompare.value) { | |||||
| toast.add({ title: '当前作品缺少原图或结果图', color: 'error' }) | |||||
| return | |||||
| } | |||||
| comparePercent.value = 50 | |||||
| compareOpen.value = true | |||||
| } | |||||
| function closeCompare() { | |||||
| compareOpen.value = false | |||||
| stopCompareDrag() | |||||
| } | |||||
| function startCompareDrag(event) { | |||||
| if (!compareSurface.value) return | |||||
| compareDragging = true | |||||
| updateComparePercent(event) | |||||
| window.addEventListener('pointermove', moveCompareDrag) | |||||
| window.addEventListener('pointerup', stopCompareDrag) | |||||
| window.addEventListener('pointercancel', stopCompareDrag) | |||||
| } | |||||
| function moveCompareDrag(event) { | |||||
| if (!compareDragging) return | |||||
| if (event.cancelable) event.preventDefault() | |||||
| updateComparePercent(event) | |||||
| } | |||||
| function stopCompareDrag() { | |||||
| compareDragging = false | |||||
| window.removeEventListener('pointermove', moveCompareDrag) | |||||
| window.removeEventListener('pointerup', stopCompareDrag) | |||||
| window.removeEventListener('pointercancel', stopCompareDrag) | |||||
| } | |||||
| function updateComparePercent(event) { | |||||
| if (!compareSurface.value) return | |||||
| const rect = compareSurface.value.getBoundingClientRect() | |||||
| const percent = ((Number(event.clientX) - rect.left) / rect.width) * 100 | |||||
| comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1)))) | |||||
| } | |||||
| function copyNames() { | |||||
| const text = buildNameCopyText(nameResult.value) | |||||
| if (!text) { | |||||
| toast.add({ title: '暂无可复制内容', color: 'error' }) | |||||
| return | |||||
| } | |||||
| navigator.clipboard.writeText(text) | |||||
| .then(() => toast.add({ title: '复制成功', color: 'success' })) | |||||
| .catch(() => toast.add({ title: '复制失败', color: 'error' })) | |||||
| } | |||||
| // 打开时加载详情 | |||||
| watch(() => props.modelValue, (open) => { | |||||
| if (open) { | |||||
| detail.value = {} | |||||
| compareOpen.value = false | |||||
| activePortraitIndex.value = 0 | |||||
| load() | |||||
| } else { | |||||
| closeCompare() | |||||
| clearPendingReload() | |||||
| } | |||||
| }) | |||||
| onBeforeUnmount(stopCompareDrag) | |||||
| </script> | |||||
| <style scoped> | |||||
| .wd-modal-enter-active, | |||||
| .wd-modal-leave-active { transition: opacity 0.2s ease; } | |||||
| .wd-modal-enter-from, | |||||
| .wd-modal-leave-to { opacity: 0; } | |||||
| .detail-scroll { | |||||
| scrollbar-width: none; | |||||
| -ms-overflow-style: none; | |||||
| } | |||||
| .detail-scroll::-webkit-scrollbar { display: none; } | |||||
| @media (prefers-reduced-motion: reduce) { | |||||
| .wd-modal-enter-active, | |||||
| .wd-modal-leave-active { transition: none; } | |||||
| } | |||||
| </style> | |||||
| @@ -0,0 +1,239 @@ | |||||
| <template> | |||||
| <Teleport to="body"> | |||||
| <Transition name="wh-fade"> | |||||
| <div v-if="modelValue" class="fixed inset-0 z-[110]"> | |||||
| <div class="absolute inset-0 bg-[var(--c-0f172a)]/45 backdrop-blur-sm" @click="close" /> | |||||
| <!-- PC:右侧抽屉;移动端:底部上滑面板(响应式) --> | |||||
| <Transition name="wh-panel"> | |||||
| <aside | |||||
| v-if="modelValue" | |||||
| class="absolute bottom-0 left-0 right-0 flex max-h-[86vh] flex-col rounded-t-2xl bg-[var(--c-ffffff)] shadow-2xl sm:left-auto sm:top-0 sm:h-full sm:max-h-none sm:w-[420px] sm:rounded-none" | |||||
| > | |||||
| <!-- 头部 --> | |||||
| <div class="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--c-eef2f7)] px-5 py-4"> | |||||
| <div> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)]">历史记录</h3> | |||||
| <div class="mt-0.5 text-xs text-[var(--c-8a97a8)]">{{ typeLabel }} · 共 {{ total }} 条</div> | |||||
| </div> | |||||
| <div class="flex items-center gap-1"> | |||||
| <button | |||||
| class="flex size-8 items-center justify-center rounded-full text-[var(--c-a0aabb)] transition-colors hover:bg-[var(--c-f1f5fa)] hover:text-[var(--c-172033)]" | |||||
| title="刷新" | |||||
| @click="fetchList(true)" | |||||
| > | |||||
| <UIcon name="i-lucide-refresh-cw" class="size-4" :class="loading && 'animate-spin'" /> | |||||
| </button> | |||||
| <button | |||||
| class="flex size-8 items-center justify-center rounded-full text-[var(--c-a0aabb)] transition-colors hover:bg-[var(--c-f1f5fa)] hover:text-[var(--c-172033)]" | |||||
| @click="close" | |||||
| > | |||||
| <UIcon name="i-lucide-x" class="size-5" /> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 列表 --> | |||||
| <div class="flex-1 overflow-y-auto px-4 py-4"> | |||||
| <!-- 未登录 --> | |||||
| <div v-if="!isLogin" class="py-16 text-center"> | |||||
| <UIcon name="i-lucide-lock" class="mx-auto mb-3 size-10 text-[var(--c-a0aabb)]" /> | |||||
| <div class="text-sm text-[var(--c-8a95a6)]">登录后查看历史记录</div> | |||||
| </div> | |||||
| <template v-else> | |||||
| <div v-if="list.length" class="space-y-2.5"> | |||||
| <button | |||||
| v-for="item in list" | |||||
| :key="item.id" | |||||
| type="button" | |||||
| class="flex w-full items-center gap-3 rounded-2xl border border-[var(--c-eef2f7)] p-3 text-left transition-colors hover:border-[var(--c-0b8cff)]/40 hover:bg-[var(--c-f7fbff)]" | |||||
| @click="select(item)" | |||||
| > | |||||
| <!-- 缩略图 / 名字预览 --> | |||||
| <div class="size-14 shrink-0 overflow-hidden rounded-xl bg-[var(--c-eef3f8)]"> | |||||
| <img | |||||
| v-if="item.cover" | |||||
| :src="item.cover" | |||||
| class="size-full object-cover" | |||||
| :alt="typeLabel" | |||||
| loading="lazy" | |||||
| > | |||||
| <div v-else class="flex size-full items-center justify-center text-[11px] font-bold text-[var(--c-8a95a8)]"> | |||||
| <span v-if="Number(item.status) === 0" class="size-5 animate-spin rounded-full border-2 border-[var(--c-0b8cff)]/25 border-t-[var(--c-0b8cff)]" /> | |||||
| <span v-else-if="item.names && item.names.length" class="px-1 text-center text-[var(--c-0b8cff)]">{{ item.names[0] }}</span> | |||||
| <span v-else>无图</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 文案 --> | |||||
| <div class="min-w-0 flex-1"> | |||||
| <div class="flex items-center gap-2"> | |||||
| <span class="truncate text-sm font-bold text-[var(--c-172033)]"> | |||||
| {{ item.names?.length ? item.names.join('、') : (item.summary || item.subtitle || typeLabel) }} | |||||
| </span> | |||||
| </div> | |||||
| <div class="mt-1.5 flex items-center gap-2"> | |||||
| <span class="rounded-full px-2 py-0.5 text-[10px] font-extrabold" :class="statusClass(item)"> | |||||
| {{ item.status_text }} | |||||
| </span> | |||||
| <span class="text-[11px] text-[var(--c-a0a8b8)]">{{ formatTime(item.add_time) }}</span> | |||||
| </div> | |||||
| </div> | |||||
| <UIcon name="i-lucide-chevron-right" class="size-4 shrink-0 text-[var(--c-c3cddb)]" /> | |||||
| </button> | |||||
| </div> | |||||
| <!-- 空态 --> | |||||
| <div v-else-if="!loading" class="py-16 text-center"> | |||||
| <UIcon name="i-lucide-history" class="mx-auto mb-3 size-10 text-[var(--c-a0aabb)]" /> | |||||
| <div class="text-sm font-bold text-[var(--c-172033)]">暂无历史记录</div> | |||||
| <div class="mt-1 text-xs text-[var(--c-8a95a6)]">生成后可在这里回看</div> | |||||
| </div> | |||||
| <!-- 加载 / 更多 --> | |||||
| <div v-if="loading" class="py-5 text-center text-xs text-[var(--c-9aa4b2)]"> | |||||
| {{ list.length ? '加载更多...' : '加载中...' }} | |||||
| </div> | |||||
| <div v-else-if="hasMore" class="mt-4 flex justify-center"> | |||||
| <button | |||||
| class="rounded-full bg-[var(--c-eef7ff)] px-6 py-2 text-xs font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)]" | |||||
| @click="loadMore" | |||||
| > | |||||
| 加载更多 | |||||
| </button> | |||||
| </div> | |||||
| </template> | |||||
| </div> | |||||
| <!-- 底部:查看全部 --> | |||||
| <div class="shrink-0 border-t border-[var(--c-eef2f7)] px-5 py-3"> | |||||
| <NuxtLink | |||||
| to="/works" | |||||
| class="flex items-center justify-center gap-1 text-sm font-bold text-[var(--c-0b8cff)] hover:underline" | |||||
| @click="close" | |||||
| > | |||||
| 查看我的全部作品 | |||||
| <UIcon name="i-lucide-arrow-right" class="size-4" /> | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </aside> | |||||
| </Transition> | |||||
| </div> | |||||
| </Transition> | |||||
| </Teleport> | |||||
| </template> | |||||
| <script setup> | |||||
| import dayjs from 'dayjs' | |||||
| const props = defineProps({ | |||||
| modelValue: { type: Boolean, default: false }, | |||||
| /** 作品类型:ainame / anime / photo / oldphoto / portrait */ | |||||
| type: { type: String, default: 'all' }, | |||||
| }) | |||||
| const emit = defineEmits(['update:modelValue', 'select']) | |||||
| const { post } = useApi() | |||||
| const { isLogin } = useUser() | |||||
| const toast = useToast() | |||||
| const list = ref([]) | |||||
| const total = ref(0) | |||||
| const page = ref(1) | |||||
| const pageSize = 10 | |||||
| const loading = ref(false) | |||||
| let timer = null | |||||
| const hasMore = computed(() => list.value.length < total.value) | |||||
| const typeLabel = computed(() => { | |||||
| const map = { ainame: 'AI 取名', anime: '动漫头像', photo: '证件照', oldphoto: '老照片', portrait: '形象照' } | |||||
| return map[props.type] || '全部作品' | |||||
| }) | |||||
| function formatTime(value) { | |||||
| return value ? dayjs(Number(value) * 1000).format('MM-DD HH:mm') : '' | |||||
| } | |||||
| function statusClass(item) { | |||||
| const s = Number(item.status) | |||||
| if (s === 0) return 'bg-[var(--c-fff5e8)] text-[var(--c-ff8a00)]' | |||||
| if (s < 0) return 'bg-[var(--c-fff0ef)] text-[var(--c-f04438)]' | |||||
| return 'bg-[var(--c-ecfbf3)] text-[var(--c-16a15d)]' | |||||
| } | |||||
| async function fetchList(reset = false, silent = false) { | |||||
| if (!isLogin.value || loading.value) return | |||||
| loading.value = true | |||||
| if (reset) page.value = 1 | |||||
| try { | |||||
| const res = await post('/work/getlist', { | |||||
| type: props.type, | |||||
| page_no: page.value, | |||||
| page_size: pageSize, | |||||
| }) | |||||
| const rows = res.list || [] | |||||
| list.value = reset ? rows : list.value.concat(rows) | |||||
| total.value = Number(res.data?.total || rows.length) | |||||
| scheduleRefresh() | |||||
| } catch (e) { | |||||
| if (!silent) toast.add({ title: e.message || '历史记录加载失败', color: 'error' }) | |||||
| } finally { | |||||
| loading.value = false | |||||
| } | |||||
| } | |||||
| // 有生成中的记录时自动刷新 | |||||
| function scheduleRefresh() { | |||||
| clearTimer() | |||||
| if (props.modelValue && list.value.some(i => Number(i.status) === 0)) { | |||||
| timer = setTimeout(() => fetchList(true, true), 3000) | |||||
| } | |||||
| } | |||||
| function clearTimer() { | |||||
| if (timer) clearTimeout(timer) | |||||
| timer = null | |||||
| } | |||||
| function loadMore() { | |||||
| if (loading.value || !hasMore.value) return | |||||
| page.value += 1 | |||||
| fetchList() | |||||
| } | |||||
| function select(item) { | |||||
| emit('select', item) | |||||
| } | |||||
| function close() { | |||||
| emit('update:modelValue', false) | |||||
| } | |||||
| watch(() => props.modelValue, (open) => { | |||||
| if (open) fetchList(true) | |||||
| else clearTimer() | |||||
| }) | |||||
| onUnmounted(() => clearTimer()) | |||||
| // 供父组件在生成成功后主动刷新 | |||||
| defineExpose({ refresh: () => fetchList(true, true) }) | |||||
| </script> | |||||
| <style scoped> | |||||
| .wh-fade-enter-active, | |||||
| .wh-fade-leave-active { transition: opacity 0.2s ease; } | |||||
| .wh-fade-enter-from, | |||||
| .wh-fade-leave-to { opacity: 0; } | |||||
| .wh-panel-enter-active, | |||||
| .wh-panel-leave-active { transition: transform 0.24s ease; } | |||||
| .wh-panel-enter-from, | |||||
| .wh-panel-leave-to { transform: translateY(100%); } | |||||
| @media (min-width: 640px) { | |||||
| .wh-panel-enter-from, | |||||
| .wh-panel-leave-to { transform: translateX(100%); } | |||||
| } | |||||
| </style> | |||||
| @@ -15,17 +15,56 @@ | |||||
| */ | */ | ||||
| import md5 from '~/utils/md5' | import md5 from '~/utils/md5' | ||||
| function parseSseBlock(block) { | |||||
| let eventName = 'message' | |||||
| const dataLines = [] | |||||
| String(block || '').split('\n').forEach((rawLine) => { | |||||
| const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine | |||||
| if (!line || line.startsWith(':')) return | |||||
| const separator = line.indexOf(':') | |||||
| const field = separator >= 0 ? line.slice(0, separator) : line | |||||
| let value = separator >= 0 ? line.slice(separator + 1) : '' | |||||
| if (value.startsWith(' ')) value = value.slice(1) | |||||
| if (field === 'event') eventName = value || 'message' | |||||
| if (field === 'data') dataLines.push(value) | |||||
| }) | |||||
| if (!dataLines.length) return null | |||||
| const dataText = dataLines.join('\n') | |||||
| try { | |||||
| return { event: eventName, data: JSON.parse(dataText) } | |||||
| } catch { | |||||
| return { event: eventName, data: { content: dataText } } | |||||
| } | |||||
| } | |||||
| export function useApi() { | export function useApi() { | ||||
| const tokenCookie = useCookie('token', { path: '/' }) | const tokenCookie = useCookie('token', { path: '/' }) | ||||
| const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' }) | const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' }) | ||||
| const openIdCookie = useCookie('open_id', { path: '/' }) | const openIdCookie = useCookie('open_id', { path: '/' }) | ||||
| const sourceCookie = useCookie('source', { path: '/' }) | const sourceCookie = useCookie('source', { path: '/' }) | ||||
| /** | |||||
| * 登录态从 useState 共享状态读取(与 useUser 同源)。 | |||||
| * 不能只依赖这里的 useCookie ref:页面若在登录前就创建了 useApi, | |||||
| * 该 ref 感知不到后续登录写入,会导致请求不带 token → 1009。 | |||||
| */ | |||||
| const tokenState = useState('auth_token', () => tokenCookie.value || '') | |||||
| const openIdState = useState('auth_open_id', () => openIdCookie.value || '') | |||||
| // mac 缺失时自动生成 | // mac 缺失时自动生成 | ||||
| if (import.meta.client && !macCookie.value) { | if (import.meta.client && !macCookie.value) { | ||||
| macCookie.value = generateUUID() | macCookie.value = generateUUID() | ||||
| } | } | ||||
| /** | |||||
| * mac 兜底值。 | |||||
| * SSR(含搜索引擎爬虫首访)时还没有 mac Cookie,而后端 base.js 要求 mac 必填, | |||||
| * 缺失会直接返回「mac必填」,导致服务端渲染取不到数据、SEO 抓不到内容。 | |||||
| */ | |||||
| const SSR_MAC = 'ssr-render' | |||||
| function generateUUID() { | function generateUUID() { | ||||
| return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { | ||||
| const r = (Math.random() * 16) | 0 | const r = (Math.random() * 16) | 0 | ||||
| @@ -50,8 +89,13 @@ export function useApi() { | |||||
| async function request(path, options = {}) { | async function request(path, options = {}) { | ||||
| const { method = 'GET', params, body } = options | const { method = 'GET', params, body } = options | ||||
| // 走 /api 代理,对齐 V2 H5 端的 BASE_URL = '/api' | |||||
| const url = '/api' + path | |||||
| /** | |||||
| * 浏览器端走 /api 代理(规避跨域,对齐 V2 H5 的 BASE_URL='/api'); | |||||
| * 服务端(SSR)直接请求后端,避免内部再走一次 Nitro 代理时 | |||||
| * form 请求体丢失导致后端报「client必填」。 | |||||
| */ | |||||
| const apiBase = useRuntimeConfig().public.apiBase | |||||
| const url = import.meta.server ? apiBase + path : '/api' + path | |||||
| const timestamp = Math.floor(Date.now() / 1000) | const timestamp = Math.floor(Date.now() / 1000) | ||||
| const { timeStr, nonce } = getNonce() | const { timeStr, nonce } = getNonce() | ||||
| @@ -62,14 +106,15 @@ export function useApi() { | |||||
| timestr: timeStr, | timestr: timeStr, | ||||
| nonce, | nonce, | ||||
| } | } | ||||
| const token = tokenCookie.value | |||||
| // 每次请求时读取最新登录态(共享 state 优先,兼容 SSR 时的 cookie) | |||||
| const token = tokenState.value || tokenCookie.value || '' | |||||
| if (token) reqHeaders.token = token | if (token) reqHeaders.token = token | ||||
| // Body 公共参数(对齐 V2 data 注入) | // Body 公共参数(对齐 V2 data 注入) | ||||
| const commonParams = { | const commonParams = { | ||||
| token: token || '', | token: token || '', | ||||
| uid: openIdCookie.value || '', | |||||
| mac: macCookie.value || '', | |||||
| uid: openIdState.value || openIdCookie.value || '', | |||||
| mac: macCookie.value || SSR_MAC, | |||||
| base_timestamp: timestamp, | base_timestamp: timestamp, | ||||
| client: 1, | client: 1, | ||||
| source: sourceCookie.value || '', | source: sourceCookie.value || '', | ||||
| @@ -113,16 +158,118 @@ export function useApi() { | |||||
| } | } | ||||
| } | } | ||||
| /** | |||||
| * POST SSE 流式请求。 | |||||
| * EventSource 不支持 POST,因此使用 fetch + ReadableStream 读取事件。 | |||||
| */ | |||||
| async function postStream(path, body = {}, handlers = {}) { | |||||
| if (import.meta.server) throw new Error('流式请求仅支持浏览器端') | |||||
| const { timeStr, nonce } = getNonce() | |||||
| const token = tokenState.value || tokenCookie.value || '' | |||||
| const requestHeaders = { | |||||
| 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', | |||||
| Accept: 'text/event-stream', | |||||
| timestr: timeStr, | |||||
| nonce, | |||||
| } | |||||
| if (token) requestHeaders.token = token | |||||
| const postBody = { | |||||
| token: token || '', | |||||
| uid: openIdState.value || openIdCookie.value || '', | |||||
| mac: macCookie.value || SSR_MAC, | |||||
| base_timestamp: Math.floor(Date.now() / 1000), | |||||
| client: 1, | |||||
| source: sourceCookie.value || '', | |||||
| client_ios: 0, | |||||
| version: '1.0.0', | |||||
| version_code: 1, | |||||
| ...body, | |||||
| stream: 1, | |||||
| } | |||||
| Object.keys(postBody).forEach((key) => { | |||||
| if (postBody[key] === '') delete postBody[key] | |||||
| }) | |||||
| const response = await fetch('/api' + path, { | |||||
| method: 'POST', | |||||
| headers: requestHeaders, | |||||
| body: new URLSearchParams(postBody).toString(), | |||||
| signal: handlers.signal, | |||||
| }) | |||||
| const contentType = response.headers.get('content-type') || '' | |||||
| // 鉴权、额度校验等错误可能在 SSE 响应头建立前以普通 JSON 返回。 | |||||
| if (!contentType.includes('text/event-stream')) { | |||||
| const responseText = await response.text() | |||||
| let payload = null | |||||
| try { payload = JSON.parse(responseText) } catch {} | |||||
| if (payload && typeof payload.code !== 'undefined') { | |||||
| const result = handleResult(payload) | |||||
| return result.data || result | |||||
| } | |||||
| const error = new Error(responseText || `流式请求失败 (${response.status})`) | |||||
| error.code = response.status | |||||
| throw error | |||||
| } | |||||
| if (!response.ok || !response.body) { | |||||
| const error = new Error(`流式请求失败 (${response.status})`) | |||||
| error.code = response.status | |||||
| throw error | |||||
| } | |||||
| const reader = response.body.getReader() | |||||
| const decoder = new TextDecoder('utf-8') | |||||
| let buffer = '' | |||||
| let doneData = null | |||||
| let streamError = null | |||||
| const consumeBlock = (block) => { | |||||
| const packet = parseSseBlock(block) | |||||
| if (!packet) return | |||||
| const data = packet.data || {} | |||||
| if (packet.event === 'start') handlers.onStart?.(data) | |||||
| else if (packet.event === 'message') handlers.onMessage?.(data) | |||||
| else if (packet.event === 'done') { | |||||
| doneData = data | |||||
| handlers.onDone?.(data) | |||||
| } else if (packet.event === 'error') { | |||||
| streamError = new Error(data.msg || '生成失败,请稍后重试') | |||||
| streamError.code = data.code || 1000 | |||||
| handlers.onError?.(data) | |||||
| } | |||||
| } | |||||
| while (true) { | |||||
| const { done, value } = await reader.read() | |||||
| buffer += decoder.decode(value || new Uint8Array(0), { stream: !done }).replace(/\r\n/g, '\n') | |||||
| const blocks = buffer.split('\n\n') | |||||
| buffer = blocks.pop() || '' | |||||
| blocks.forEach(consumeBlock) | |||||
| if (done) break | |||||
| if (streamError) { | |||||
| await reader.cancel() | |||||
| break | |||||
| } | |||||
| } | |||||
| if (buffer.trim()) consumeBlock(buffer) | |||||
| if (streamError) throw streamError | |||||
| if (!doneData) throw new Error('流式响应意外中断,请重试') | |||||
| return doneData | |||||
| } | |||||
| /** | /** | ||||
| * 统一处理响应 | * 统一处理响应 | ||||
| */ | */ | ||||
| function handleResult(result) { | function handleResult(result) { | ||||
| if (result.code === 1009) { | if (result.code === 1009) { | ||||
| // 登录失效 → 触发登录弹窗 | |||||
| // 登录失效 → 清理登录态并触发登录弹窗 | |||||
| if (import.meta.client) { | if (import.meta.client) { | ||||
| const { logout } = useUser() | |||||
| const { logout, showLogin } = useUser() | |||||
| logout() | logout() | ||||
| const showLogin = useState('showLogin', () => false) | |||||
| showLogin.value = true | showLogin.value = true | ||||
| } | } | ||||
| const err = new Error(result.msg || '登录已失效,请重新登录') | const err = new Error(result.msg || '登录已失效,请重新登录') | ||||
| @@ -144,6 +291,8 @@ export function useApi() { | |||||
| get: (path, params) => request(path, { method: 'GET', params }), | get: (path, params) => request(path, { method: 'GET', params }), | ||||
| /** POST 请求 (x-www-form-urlencoded) */ | /** POST 请求 (x-www-form-urlencoded) */ | ||||
| post: (path, body) => request(path, { method: 'POST', body }), | post: (path, body) => request(path, { method: 'POST', body }), | ||||
| /** POST SSE 流式请求 */ | |||||
| postStream, | |||||
| /** 通用请求 */ | /** 通用请求 */ | ||||
| request, | request, | ||||
| } | } | ||||
| @@ -0,0 +1,169 @@ | |||||
| /** | |||||
| * AI 资讯(文章)模块共用逻辑 | |||||
| * | |||||
| * 栏目与路由完全沿用原 PC 站,保证 SEO 不断链: | |||||
| * col_id 1 前沿资讯 → /info/new 详情 /info/new/{id}.html | |||||
| * col_id 2 使用教程 → /info/course 详情 /info/course/{id}.html | |||||
| * col_id 3 AI 问答 → /info/what 详情 /info/what/{id}.html | |||||
| * 标签页 → /info/tag/{key} | |||||
| * 资讯首页(全部) → /info | |||||
| */ | |||||
| import dayjs from 'dayjs' | |||||
| import { | |||||
| LEGACY_DEFAULT_DESCRIPTION, | |||||
| LEGACY_DEFAULT_KEYWORDS, | |||||
| legacyPageTitle, | |||||
| } from '~/composables/useLegacySeo' | |||||
| /** 栏目定义 */ | |||||
| export const COLUMNS = [ | |||||
| { colId: 1, key: 'new', name: '前沿资讯', path: '/info/new', icon: 'i-lucide-newspaper' }, | |||||
| { colId: 2, key: 'course', name: '使用教程', path: '/info/course', icon: 'i-lucide-graduation-cap' }, | |||||
| { colId: 3, key: 'what', name: 'AI 问答', path: '/info/what', icon: 'i-lucide-circle-help' }, | |||||
| ] | |||||
| /** col_id → 详情 URL(保留 .html 后缀,与原站一致) */ | |||||
| export function articleUrl(item) { | |||||
| const col = COLUMNS.find(c => c.colId === Number(item?.col_id)) || COLUMNS[0] | |||||
| return `${col.path}/${item.id}.html` | |||||
| } | |||||
| /** col_id → 栏目名 */ | |||||
| export function colName(colId) { | |||||
| return COLUMNS.find(c => c.colId === Number(colId))?.name || '资讯' | |||||
| } | |||||
| /** col_id → 栏目列表页路径 */ | |||||
| export function colPath(colId) { | |||||
| return COLUMNS.find(c => c.colId === Number(colId))?.path || '/info' | |||||
| } | |||||
| /** 列表分页 URL(沿用原站:/info2、/info/new2、/info/tag/key-2.html) */ | |||||
| export function pageUrl(basePath, page, tagKey) { | |||||
| if (tagKey) return page > 1 ? `/info/tag/${tagKey}-${page}.html` : `/info/tag/${tagKey}.html` | |||||
| return page > 1 ? `${basePath}${page}` : basePath | |||||
| } | |||||
| /** 秒级时间戳格式化 */ | |||||
| export function formatTime(value, fmt = 'YYYY-MM-DD HH:mm') { | |||||
| if (!value) return '' | |||||
| return dayjs(Number(value) * 1000).format(fmt) | |||||
| } | |||||
| /** 从路由参数中取出文章 id(去掉 .html 后缀) */ | |||||
| export function parseArticleId(param) { | |||||
| return String(param || '').replace(/\.html$/i, '') | |||||
| } | |||||
| /** | |||||
| * 资讯页共用数据:列表 + 热门排行 + 热门标签 | |||||
| * 用 useAsyncData 做 SSR,保证爬虫可直接抓到内容 | |||||
| */ | |||||
| export function useArticleData(options) { | |||||
| const { key, colId, tagKey, page, pageSize = 10, isRecommend } = options | |||||
| const { post } = useApi() | |||||
| return useAsyncData( | |||||
| key, | |||||
| async () => { | |||||
| const listParams = { page_no: unref(page) || 1, page_size: pageSize } | |||||
| if (colId) listParams.col_id = colId | |||||
| if (tagKey) listParams.tag_key = unref(tagKey) | |||||
| if (isRecommend) listParams.is_recommend = 1 | |||||
| const [listRes, hotRes, tagRes] = await Promise.all([ | |||||
| post('/article/getlist', listParams).catch(() => ({ list: [], data: {} })), | |||||
| post('/article/gethotlist', { page_no: 1, page_size: 10 }).catch(() => ({ list: [] })), | |||||
| post('/article/gettaglist', { is_hot: 1, page_size: 20 }).catch(() => ({ list: [] })), | |||||
| ]) | |||||
| return { | |||||
| list: (listRes.list || []).map(i => ({ ...i, url: articleUrl(i), time: formatTime(i.publish_time) })), | |||||
| total: Number(listRes.data?.total || 0), | |||||
| tagName: listRes.data?.tag_name || '', | |||||
| hotList: (hotRes.list || []).map((i, idx) => ({ ...i, rank: idx + 1, url: articleUrl(i) })), | |||||
| tagList: tagRes.list || [], | |||||
| } | |||||
| }, | |||||
| { watch: [page, tagKey].filter(Boolean) }, | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * 文章详情共用数据(SSR),并统一处理 SEO | |||||
| * @param {number} colId 当前栏目(用于校验路由与文章栏目是否一致) | |||||
| */ | |||||
| export async function useArticleDetail(colId) { | |||||
| const route = useRoute() | |||||
| const nuxtApp = useNuxtApp() | |||||
| const { post } = useApi() | |||||
| const id = parseArticleId(route.params.id) | |||||
| /** | |||||
| * 注意:所有依赖 Nuxt 上下文的组合式函数(useSeoMeta / useHead / navigateTo) | |||||
| * 必须在 await 之前调用,await 之后上下文会丢失(NUXT_E1001)。 | |||||
| * 因此这里先注册 SEO(用 getter 读取 data),最后再 await 数据做 404 判断。 | |||||
| */ | |||||
| const asyncData = useAsyncData(`article-${id}`, async () => { | |||||
| const [detailRes, hotRes, tagRes] = await Promise.all([ | |||||
| post('/article/getdetail', { id, prev_next: 1 }), | |||||
| post('/article/gethotlist', { page_no: 1, page_size: 10 }).catch(() => ({ list: [] })), | |||||
| post('/article/gettaglist', { is_hot: 1, page_size: 20 }).catch(() => ({ list: [] })), | |||||
| ]) | |||||
| return { | |||||
| detail: detailRes.data || {}, | |||||
| hotList: (hotRes.list || []).map((i, idx) => ({ ...i, rank: idx + 1, url: articleUrl(i) })), | |||||
| tagList: tagRes.list || [], | |||||
| } | |||||
| }) | |||||
| const { data, error } = asyncData | |||||
| const detail = computed(() => data.value?.detail || {}) | |||||
| const canonical = computed(() => `https://tool.aionline.cc${articleUrl(detail.value)}`) | |||||
| useSeoMeta({ | |||||
| title: () => legacyPageTitle(detail.value.title), | |||||
| description: () => detail.value.info || LEGACY_DEFAULT_DESCRIPTION, | |||||
| keywords: () => detail.value.title || LEGACY_DEFAULT_KEYWORDS, | |||||
| ogTitle: () => detail.value.title, | |||||
| ogDescription: () => detail.value.info || detail.value.title, | |||||
| ogType: 'article', | |||||
| ogImage: () => detail.value.cover || '', | |||||
| articlePublishedTime: () => formatTime(detail.value.publish_time, 'YYYY-MM-DDTHH:mm:ssZ'), | |||||
| }) | |||||
| useHead({ | |||||
| link: [{ rel: 'canonical', href: canonical }], | |||||
| // 结构化数据,利于搜索结果富摘要 | |||||
| script: [{ | |||||
| type: 'application/ld+json', | |||||
| innerHTML: computed(() => JSON.stringify({ | |||||
| '@context': 'https://schema.org', | |||||
| '@type': 'Article', | |||||
| headline: detail.value.title, | |||||
| description: detail.value.info || '', | |||||
| image: detail.value.cover ? [detail.value.cover] : undefined, | |||||
| datePublished: formatTime(detail.value.publish_time, 'YYYY-MM-DD'), | |||||
| author: { '@type': 'Organization', name: 'AI在线' }, | |||||
| publisher: { '@type': 'Organization', name: 'AI在线' }, | |||||
| mainEntityOfPage: { '@type': 'WebPage', '@id': canonical.value }, | |||||
| })), | |||||
| }], | |||||
| }) | |||||
| // 等数据就绪后再判断(await 之后不能再调用需要上下文的组合式函数) | |||||
| await asyncData | |||||
| // 文章不存在 → 404,避免无效页面被搜索引擎收录 | |||||
| if (error.value || !data.value?.detail?.id) { | |||||
| throw createError({ statusCode: 404, statusMessage: '文章不存在', fatal: true }) | |||||
| } | |||||
| // 访问的栏目路径与文章实际栏目不一致 → 301 到规范地址(避免重复内容) | |||||
| if (colId && Number(detail.value.col_id) !== Number(colId)) { | |||||
| await nuxtApp.runWithContext(() => | |||||
| navigateTo(articleUrl(detail.value), { redirectCode: 301, replace: true })) | |||||
| } | |||||
| return { data, detail } | |||||
| } | |||||
| @@ -0,0 +1,45 @@ | |||||
| /** | |||||
| * 点数获取(任务)配置 | |||||
| * | |||||
| * 后端 /point/earnconfig 一次返回签到、邀请与看广告任务的开关、额度与今日进度, | |||||
| * entry_enabled 为 false 表示全部任务都关闭,此时所有入口都不渲染。 | |||||
| * | |||||
| * 结果放在 useState 里共享,避免 header、充值页、账单页各请求一次。 | |||||
| */ | |||||
| export function useEarnPoints() { | |||||
| const { post } = useApi() | |||||
| const config = useState('earn-points-config', () => null) | |||||
| const loading = useState('earn-points-loading', () => false) | |||||
| const entryEnabled = computed(() => Boolean(config.value?.entry_enabled)) | |||||
| const adTask = computed(() => config.value?.ad || { enabled: false }) | |||||
| const inviteTask = computed(() => config.value?.invite || { enabled: false }) | |||||
| const checkinTask = computed(() => config.value?.checkin || { enabled: false }) | |||||
| const balancePoints = computed(() => Number(config.value?.balance?.points || 0)) | |||||
| /** | |||||
| * @param {boolean} force 忽略已有缓存重新拉取(领取到账后刷新用) | |||||
| */ | |||||
| async function load(force = false) { | |||||
| if (loading.value) return config.value | |||||
| if (config.value && !force) return config.value | |||||
| loading.value = true | |||||
| try { | |||||
| const res = await post('/point/earnconfig', {}) | |||||
| config.value = res.data || {} | |||||
| } catch (e) { | |||||
| // 拉取失败按未开启处理,宁可不显示入口,也不给一个点进去报错的入口 | |||||
| config.value = { | |||||
| entry_enabled: false, | |||||
| ad: { enabled: false }, | |||||
| invite: { enabled: false }, | |||||
| checkin: { enabled: false }, | |||||
| } | |||||
| } finally { | |||||
| loading.value = false | |||||
| } | |||||
| return config.value | |||||
| } | |||||
| return { config, loading, entryEnabled, adTask, inviteTask, checkinTask, balancePoints, load } | |||||
| } | |||||
| @@ -0,0 +1,98 @@ | |||||
| import md5 from '~/utils/md5' | |||||
| export function useIdPhoto() { | |||||
| 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: '/' }) | |||||
| const tokenState = useState('auth_token', () => tokenCookie.value || '') | |||||
| const openIdState = useState('auth_open_id', () => openIdCookie.value || '') | |||||
| if (import.meta.client && !macCookie.value) { | |||||
| macCookie.value = createUuid() | |||||
| } | |||||
| function createUuid() { | |||||
| if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID() | |||||
| return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => { | |||||
| const random = Math.floor(Math.random() * 16) | |||||
| const value = char === 'x' ? random : (random & 0x3) | 0x8 | |||||
| return value.toString(16) | |||||
| }) | |||||
| } | |||||
| function getNonce() { | |||||
| const timeStr = Date.now().toString() | |||||
| return { | |||||
| timeStr, | |||||
| nonce: md5(timeStr).substring(4, 14), | |||||
| } | |||||
| } | |||||
| async function create(file, settings) { | |||||
| if (!file) throw new Error('请选择一张照片') | |||||
| const token = tokenState.value || tokenCookie.value || '' | |||||
| const { timeStr, nonce } = getNonce() | |||||
| const formData = new FormData() | |||||
| // ThinkJS 2 会先读取公共参数,必须将普通字段放在文件字段之前。 | |||||
| formData.append('token', token) | |||||
| formData.append('uid', openIdState.value || openIdCookie.value || '') | |||||
| formData.append('mac', macCookie.value || 'pc-id-photo') | |||||
| formData.append('base_timestamp', String(Math.floor(Date.now() / 1000))) | |||||
| 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('request_id', settings.request_id || createUuid()) | |||||
| formData.append('spec_key', settings.spec_key) | |||||
| formData.append('background', settings.background) | |||||
| formData.append('beauty', settings.beauty) | |||||
| formData.append('scale', String(settings.scale)) | |||||
| formData.append('offset_x', String(settings.offset_x)) | |||||
| formData.append('offset_y', String(settings.offset_y)) | |||||
| formData.append('file', file) | |||||
| let result | |||||
| try { | |||||
| result = await $fetch('/api/idphoto/create', { | |||||
| method: 'POST', | |||||
| headers: { | |||||
| token, | |||||
| timestr: timeStr, | |||||
| nonce, | |||||
| }, | |||||
| body: formData, | |||||
| }) | |||||
| } catch (error) { | |||||
| const data = error?.data | |||||
| if (data && typeof data === 'object' && data.msg) { | |||||
| const normalized = new Error(data.msg) | |||||
| normalized.code = data.code || 1000 | |||||
| normalized.data = data.data || {} | |||||
| throw normalized | |||||
| } | |||||
| throw error | |||||
| } | |||||
| if (result.code === 1009) { | |||||
| const { logout, showLogin } = useUser() | |||||
| logout() | |||||
| showLogin.value = true | |||||
| } | |||||
| if (result.code !== 0) { | |||||
| const error = new Error(result.msg || '证件照制作失败') | |||||
| error.code = result.code || 1000 | |||||
| error.data = result.data || {} | |||||
| throw error | |||||
| } | |||||
| return result | |||||
| } | |||||
| return { | |||||
| create, | |||||
| createRequestId: createUuid, | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,87 @@ | |||||
| const INVITE_MAX_AGE = 30 * 24 * 3600 | |||||
| function normalizeInviteCode(value) { | |||||
| const text = Array.isArray(value) ? value[0] : value | |||||
| const code = String(text || '').trim() | |||||
| return /^[a-zA-Z0-9_-]{1,64}$/.test(code) ? code : '' | |||||
| } | |||||
| function parseInviteScene(value) { | |||||
| if (!value) return '' | |||||
| let text = String(Array.isArray(value) ? value[0] : value) | |||||
| try { | |||||
| text = decodeURIComponent(text) | |||||
| } catch { | |||||
| // 保留原始 scene,交给后续格式校验。 | |||||
| } | |||||
| const match = text.match(/(?:^|[&?])i=([^&]+)/) || text.match(/(?:^|[&?])i_([^&]+)/) | |||||
| if (match) return normalizeInviteCode(match[1]) | |||||
| if (text.startsWith('i_')) return normalizeInviteCode(text.slice(2)) | |||||
| return '' | |||||
| } | |||||
| export function useInvite() { | |||||
| const inviteCode = useCookie('invite_code', { | |||||
| maxAge: INVITE_MAX_AGE, | |||||
| path: '/', | |||||
| sameSite: 'lax', | |||||
| }) | |||||
| const source = useCookie('source', { | |||||
| maxAge: INVITE_MAX_AGE, | |||||
| path: '/', | |||||
| sameSite: 'lax', | |||||
| }) | |||||
| const runtimeConfig = useRuntimeConfig() | |||||
| const requestUrl = useRequestURL() | |||||
| function captureInvite(query = {}) { | |||||
| const token = useCookie('token', { path: '/' }) | |||||
| const code = normalizeInviteCode(query.invite_code) | |||||
| || normalizeInviteCode(query.invite_open_id) | |||||
| || parseInviteScene(query.scene) | |||||
| // 已登录用户不会再触发注册奖励,避免在共享设备上污染下一位登录用户。 | |||||
| if (code && !token.value) inviteCode.value = code | |||||
| const sourceValue = String(Array.isArray(query.source) ? query.source[0] : query.source || '').trim() | |||||
| if (sourceValue && sourceValue.length <= 50) source.value = sourceValue | |||||
| return code | |||||
| } | |||||
| function withInvite(params = {}) { | |||||
| const code = normalizeInviteCode(inviteCode.value) | |||||
| return code ? { ...params, invite_open_id: code } : { ...params } | |||||
| } | |||||
| function clearInvite() { | |||||
| inviteCode.value = null | |||||
| } | |||||
| function buildInvitePath(code) { | |||||
| const normalized = normalizeInviteCode(code) | |||||
| if (!normalized) return '/invite/landing' | |||||
| const query = new URLSearchParams({ | |||||
| invite_code: normalized, | |||||
| source: 'pc_invite', | |||||
| }) | |||||
| return `/invite/landing?${query.toString()}` | |||||
| } | |||||
| function buildInviteUrl(code) { | |||||
| const configuredOrigin = String(runtimeConfig.public.siteUrl || '').replace(/\/+$/, '') | |||||
| const origin = configuredOrigin || requestUrl.origin | |||||
| return `${origin}${buildInvitePath(code)}` | |||||
| } | |||||
| return { | |||||
| inviteCode, | |||||
| source, | |||||
| captureInvite, | |||||
| withInvite, | |||||
| clearInvite, | |||||
| buildInvitePath, | |||||
| buildInviteUrl, | |||||
| normalizeInviteCode, | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,54 @@ | |||||
| /** | |||||
| * 原 PC 站 SEO 基线。 | |||||
| * | |||||
| * 已上线页面继续沿用旧站的标题分隔符、默认描述与关键词,避免重构后 | |||||
| * 搜索引擎把同一页面识别成一套全新的内容。新功能页可继续使用自己的 TDK。 | |||||
| */ | |||||
| export const LEGACY_SITE_URL = 'https://tool.aionline.cc' | |||||
| export const LEGACY_DEFAULT_DESCRIPTION = 'AI在线是一个 AI 工具的集合地,可以找到各种各样的 AI 工具,满足你的不同需求。无论你是想翻译一段文字,还是想生成一篇文章,或者是想制作一张图片,AI在线都能为你提供帮助, AI 工具大全宝库!' | |||||
| export const LEGACY_DEFAULT_KEYWORDS = 'AI,AI在线,AI伙伴,免费AI网站' | |||||
| /** 原站非首页统一使用「页面标题_AI在线」 */ | |||||
| export function legacyPageTitle(title) { | |||||
| return `${String(title || 'AI在线').trim()}_AI在线` | |||||
| } | |||||
| /** | |||||
| * 沿用旧 PC 站的应用 TDK 数据源:后台 app.page_title/description/keywords。 | |||||
| * 本地开发库未配置旧应用时使用线上旧站当前 TDK 作为兜底。 | |||||
| */ | |||||
| export function useLegacyAppSeo({ | |||||
| appKey, | |||||
| fallbackTitle, | |||||
| fallbackDescription = LEGACY_DEFAULT_DESCRIPTION, | |||||
| fallbackKeywords = LEGACY_DEFAULT_KEYWORDS, | |||||
| canonicalPath, | |||||
| }) { | |||||
| const { post } = useApi() | |||||
| const asyncData = useAsyncData(`legacy-app-seo-${appKey}`, () => | |||||
| post('/app/getdetail', { key: appKey }).catch(() => ({ data: {} }))) | |||||
| const appInfo = computed(() => asyncData.data.value?.data || {}) | |||||
| const title = computed(() => | |||||
| legacyPageTitle(appInfo.value.page_title || fallbackTitle)) | |||||
| const description = computed(() => | |||||
| appInfo.value.description || fallbackDescription) | |||||
| const keywords = computed(() => | |||||
| appInfo.value.keywords || fallbackKeywords) | |||||
| useSeoMeta({ | |||||
| title, | |||||
| description, | |||||
| keywords, | |||||
| }) | |||||
| if (canonicalPath) { | |||||
| useHead({ | |||||
| link: [{ rel: 'canonical', href: `${LEGACY_SITE_URL}${canonicalPath}` }], | |||||
| }) | |||||
| } | |||||
| return asyncData | |||||
| } | |||||
| @@ -0,0 +1,98 @@ | |||||
| import md5 from '~/utils/md5' | |||||
| /** | |||||
| * 老照片修复 / 上色(混元生图 3.0) | |||||
| * | |||||
| * create 走 multipart 直传原图(和证件照一样,避免先传 CDN 再由后端回源下载一次), | |||||
| * 其余查询类接口用 useApi() 的普通 POST 即可。 | |||||
| */ | |||||
| export function useOldPhoto() { | |||||
| 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: '/' }) | |||||
| const tokenState = useState('auth_token', () => tokenCookie.value || '') | |||||
| const openIdState = useState('auth_open_id', () => openIdCookie.value || '') | |||||
| if (import.meta.client && !macCookie.value) { | |||||
| macCookie.value = createUuid() | |||||
| } | |||||
| function createUuid() { | |||||
| if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID() | |||||
| return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => { | |||||
| const random = Math.floor(Math.random() * 16) | |||||
| const value = char === 'x' ? random : (random & 0x3) | 0x8 | |||||
| return value.toString(16) | |||||
| }) | |||||
| } | |||||
| function getNonce() { | |||||
| const timeStr = Date.now().toString() | |||||
| return { timeStr, nonce: md5(timeStr).substring(4, 14) } | |||||
| } | |||||
| /** | |||||
| * 提交修复任务 | |||||
| * @param {File} file 原图 | |||||
| * @param {{ mode: string, extra_prompt?: string, request_id?: string }} settings | |||||
| */ | |||||
| async function create(file, settings = {}) { | |||||
| if (!file) throw new Error('请选择一张照片') | |||||
| const token = tokenState.value || tokenCookie.value || '' | |||||
| const { timeStr, nonce } = getNonce() | |||||
| const formData = new FormData() | |||||
| // ThinkJS 2 会先读公共参数,普通字段必须排在文件字段之前 | |||||
| formData.append('token', token) | |||||
| formData.append('uid', openIdState.value || openIdCookie.value || '') | |||||
| formData.append('mac', macCookie.value || 'pc-old-photo') | |||||
| formData.append('base_timestamp', String(Math.floor(Date.now() / 1000))) | |||||
| 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('request_id', settings.request_id || createUuid()) | |||||
| formData.append('mode', settings.mode || 'colorize') | |||||
| if (settings.extra_prompt) formData.append('extra_prompt', settings.extra_prompt) | |||||
| formData.append('file', file) | |||||
| let result | |||||
| try { | |||||
| result = await $fetch('/api/oldphoto/create', { | |||||
| method: 'POST', | |||||
| headers: { token, timestr: timeStr, nonce }, | |||||
| body: formData, | |||||
| }) | |||||
| } catch (error) { | |||||
| const data = error?.data | |||||
| if (data && typeof data === 'object' && data.msg) { | |||||
| const normalized = new Error(data.msg) | |||||
| normalized.code = data.code || 1000 | |||||
| normalized.data = data.data || {} | |||||
| throw normalized | |||||
| } | |||||
| throw error | |||||
| } | |||||
| if (result.code === 1009) { | |||||
| const { logout, showLogin } = useUser() | |||||
| logout() | |||||
| showLogin.value = true | |||||
| } | |||||
| if (result.code !== 0) { | |||||
| const error = new Error(result.msg || '提交失败,请稍后重试') | |||||
| error.code = result.code || 1000 | |||||
| error.data = result.data || {} | |||||
| throw error | |||||
| } | |||||
| return result | |||||
| } | |||||
| return { | |||||
| create, | |||||
| createRequestId: createUuid, | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,107 @@ | |||||
| import md5 from '~/utils/md5' | |||||
| /** | |||||
| * 形象照 / 职业照 / 艺术写真(混元生图 3.0) | |||||
| * | |||||
| * 与老照片同一套链路:create 走 multipart 直传原图,避免先传 CDN 再由后端回源下载一次; | |||||
| * 配置、详情、列表等查询类接口用 useApi() 的普通 POST。 | |||||
| * 一次提交 1~4 张,每张是一个独立的混元子任务、独立计费,失败的不扣点。 | |||||
| */ | |||||
| export function usePortrait() { | |||||
| 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: '/' }) | |||||
| const tokenState = useState('auth_token', () => tokenCookie.value || '') | |||||
| const openIdState = useState('auth_open_id', () => openIdCookie.value || '') | |||||
| if (import.meta.client && !macCookie.value) { | |||||
| macCookie.value = createUuid() | |||||
| } | |||||
| function createUuid() { | |||||
| if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID() | |||||
| return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => { | |||||
| const random = Math.floor(Math.random() * 16) | |||||
| const value = char === 'x' ? random : (random & 0x3) | 0x8 | |||||
| return value.toString(16) | |||||
| }) | |||||
| } | |||||
| function getNonce() { | |||||
| const timeStr = Date.now().toString() | |||||
| return { timeStr, nonce: md5(timeStr).substring(4, 14) } | |||||
| } | |||||
| /** | |||||
| * 提交生成任务 | |||||
| * @param {File} file 人像原图 | |||||
| * @param {object} settings { style, model, ratio, count, extra_prompt, request_id, options } | |||||
| * options 是「选项组 key -> 选项 key」的映射(如 { background: 'white', outfit: 'formal' }), | |||||
| * 后端按这些 key 拼提示词,端上不传提示词本身。 | |||||
| */ | |||||
| async function create(file, settings = {}) { | |||||
| if (!file) throw new Error('请选择一张照片') | |||||
| const token = tokenState.value || tokenCookie.value || '' | |||||
| const { timeStr, nonce } = getNonce() | |||||
| const formData = new FormData() | |||||
| // ThinkJS 2 会先读公共参数,普通字段必须排在文件字段之前 | |||||
| formData.append('token', token) | |||||
| formData.append('uid', openIdState.value || openIdCookie.value || '') | |||||
| formData.append('mac', macCookie.value || 'pc-portrait') | |||||
| formData.append('base_timestamp', String(Math.floor(Date.now() / 1000))) | |||||
| 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('request_id', settings.request_id || createUuid()) | |||||
| formData.append('style', settings.style || '') | |||||
| formData.append('model', settings.model || '') | |||||
| formData.append('ratio', settings.ratio || '') | |||||
| formData.append('count', String(settings.count || 1)) | |||||
| if (settings.extra_prompt) formData.append('extra_prompt', settings.extra_prompt) | |||||
| Object.entries(settings.options || {}).forEach(([key, value]) => { | |||||
| if (value) formData.append(key, value) | |||||
| }) | |||||
| formData.append('file', file) | |||||
| let result | |||||
| try { | |||||
| result = await $fetch('/api/portrait/create', { | |||||
| method: 'POST', | |||||
| headers: { token, timestr: timeStr, nonce }, | |||||
| body: formData, | |||||
| }) | |||||
| } catch (error) { | |||||
| const data = error?.data | |||||
| if (data && typeof data === 'object' && data.msg) { | |||||
| const normalized = new Error(data.msg) | |||||
| normalized.code = data.code || 1000 | |||||
| normalized.data = data.data || {} | |||||
| throw normalized | |||||
| } | |||||
| throw error | |||||
| } | |||||
| if (result.code === 1009) { | |||||
| const { logout, showLogin } = useUser() | |||||
| logout() | |||||
| showLogin.value = true | |||||
| } | |||||
| if (result.code !== 0) { | |||||
| const error = new Error(result.msg || '提交失败,请稍后重试') | |||||
| error.code = result.code || 1000 | |||||
| error.data = result.data || {} | |||||
| throw error | |||||
| } | |||||
| return result | |||||
| } | |||||
| return { | |||||
| create, | |||||
| createRequestId: createUuid, | |||||
| } | |||||
| } | |||||
| @@ -34,10 +34,8 @@ export function useUpload() { | |||||
| const { timeStr, nonce } = getNonce() | const { timeStr, nonce } = getNonce() | ||||
| const formData = new FormData() | const formData = new FormData() | ||||
| // 文件字段(最先添加) | |||||
| formData.append('file', file) | |||||
| // 公共参数(对齐 V2 upload.js) | |||||
| // ThinkJS 2 会在控制器 __before() 中先校验公共参数,因此这些字段 | |||||
| // 必须位于文件字段之前,否则 multipart 解析时会误报“client必填”。 | |||||
| formData.append('token', tokenCookie.value || '') | formData.append('token', tokenCookie.value || '') | ||||
| formData.append('uid', openIdCookie.value || '') | formData.append('uid', openIdCookie.value || '') | ||||
| formData.append('mac', macCookie.value || '') | formData.append('mac', macCookie.value || '') | ||||
| @@ -48,6 +46,7 @@ export function useUpload() { | |||||
| formData.append('version', '1.0.0') | formData.append('version', '1.0.0') | ||||
| formData.append('version_code', '1') | formData.append('version_code', '1') | ||||
| formData.append('upload_type', '1') | formData.append('upload_type', '1') | ||||
| formData.append('file', file) | |||||
| // 走 /api 代理,对齐 V2 | // 走 /api 代理,对齐 V2 | ||||
| const url = '/api/common/uploadpic' | const url = '/api/common/uploadpic' | ||||
| @@ -5,11 +5,25 @@ | |||||
| * Token 持久化:Cookie(SSR 安全,Nuxt useCookie 自动同步服务端/客户端) | * Token 持久化:Cookie(SSR 安全,Nuxt useCookie 自动同步服务端/客户端) | ||||
| */ | */ | ||||
| export function useUser() { | export function useUser() { | ||||
| const token = useCookie('token', { | |||||
| const tokenCookie = useCookie('token', { | |||||
| maxAge: 7 * 24 * 3600, | maxAge: 7 * 24 * 3600, | ||||
| path: '/', | path: '/', | ||||
| }) | }) | ||||
| /** | |||||
| * 登录态用 useState 全局共享。 | |||||
| * 注意:不能直接把 useCookie 的 ref 当共享状态——每次 useCookie('token') 都会返回 | |||||
| * 一个独立的 ref,页面在登录前创建的 ref 不会感知到登录后的写入, | |||||
| * 会导致请求不带 token、后端返回 1009。 | |||||
| */ | |||||
| const token = useState('auth_token', () => tokenCookie.value || '') | |||||
| /** 同时写入共享状态与 Cookie */ | |||||
| function setToken(value) { | |||||
| token.value = value || '' | |||||
| tokenCookie.value = value || null | |||||
| } | |||||
| const userInfo = useState('userInfo', () => ({ | const userInfo = useState('userInfo', () => ({ | ||||
| user_id: null, | user_id: null, | ||||
| user_name: '', | user_name: '', | ||||
| @@ -20,7 +34,12 @@ export function useUser() { | |||||
| balance: 0, | balance: 0, | ||||
| })) | })) | ||||
| const isLogin = computed(() => !!token.value && !!userInfo.value.user_id) | |||||
| /** | |||||
| * 只以 token 判断登录态(对齐 V2)。 | |||||
| * 不能附加 userInfo.user_id 条件:刷新页面时用户信息是异步拉取的, | |||||
| * 否则页面首次渲染会被判为未登录,导致该拉的数据不拉、还误弹登录框。 | |||||
| */ | |||||
| const isLogin = computed(() => !!token.value) | |||||
| // 登录弹窗全局状态(任何组件都可以触发) | // 登录弹窗全局状态(任何组件都可以触发) | ||||
| const showLogin = useState('showLogin', () => false) | const showLogin = useState('showLogin', () => false) | ||||
| @@ -29,7 +48,7 @@ export function useUser() { | |||||
| * 设置登录态 | * 设置登录态 | ||||
| */ | */ | ||||
| function setLogin(data) { | function setLogin(data) { | ||||
| token.value = data.token | |||||
| setToken(data.token) | |||||
| userInfo.value = { | userInfo.value = { | ||||
| user_id: data.user_id, | user_id: data.user_id, | ||||
| user_name: data.user_name || data.nickname || '', | user_name: data.user_name || data.nickname || '', | ||||
| @@ -39,10 +58,11 @@ export function useUser() { | |||||
| vip_type: data.vip_type || 0, | vip_type: data.vip_type || 0, | ||||
| balance: data.balance || 0, | balance: data.balance || 0, | ||||
| } | } | ||||
| // 同步写入 open_id Cookie(供 useApi 读取 uid) | |||||
| // 同步写入 open_id(共享状态 + Cookie,供 useApi 读取 uid) | |||||
| if (data.open_id) { | if (data.open_id) { | ||||
| const openIdCookie = useCookie('open_id', { path: '/' }) | const openIdCookie = useCookie('open_id', { path: '/' }) | ||||
| openIdCookie.value = data.open_id | openIdCookie.value = data.open_id | ||||
| useState('auth_open_id', () => '').value = data.open_id | |||||
| } | } | ||||
| } | } | ||||
| @@ -50,7 +70,7 @@ export function useUser() { | |||||
| * 退出登录 | * 退出登录 | ||||
| */ | */ | ||||
| function logout() { | function logout() { | ||||
| token.value = null | |||||
| setToken('') | |||||
| userInfo.value = { | userInfo.value = { | ||||
| user_id: null, | user_id: null, | ||||
| user_name: '', | user_name: '', | ||||
| @@ -60,9 +80,10 @@ export function useUser() { | |||||
| vip_type: 0, | vip_type: 0, | ||||
| balance: 0, | balance: 0, | ||||
| } | } | ||||
| // 清除 open_id Cookie | |||||
| // 清除 open_id | |||||
| const openIdCookie = useCookie('open_id', { path: '/' }) | const openIdCookie = useCookie('open_id', { path: '/' }) | ||||
| openIdCookie.value = null | openIdCookie.value = null | ||||
| useState('auth_open_id', () => '').value = '' | |||||
| } | } | ||||
| /** | /** | ||||
| @@ -84,6 +105,24 @@ export function useUser() { | |||||
| } | } | ||||
| } | } | ||||
| /** | |||||
| * 刷新点数余额。 | |||||
| * 注意:/user/getuserinfo 不返回点数,必须单独查 /point/balance, | |||||
| * 否则 header 会一直显示登录时的旧值(通常是 0)。 | |||||
| */ | |||||
| async function refreshBalance() { | |||||
| if (!token.value) return 0 | |||||
| try { | |||||
| const { post } = useApi() | |||||
| const res = await post('/point/balance') | |||||
| const points = res?.data?.points ?? res?.data?.point_balance ?? 0 | |||||
| userInfo.value = { ...userInfo.value, balance: Number(points) || 0 } | |||||
| return userInfo.value.balance | |||||
| } catch { | |||||
| return userInfo.value.balance || 0 | |||||
| } | |||||
| } | |||||
| /** | /** | ||||
| * 初始化:如果有 token 但无 userInfo,自动刷新 | * 初始化:如果有 token 但无 userInfo,自动刷新 | ||||
| */ | */ | ||||
| @@ -110,12 +149,14 @@ export function useUser() { | |||||
| return { | return { | ||||
| token, | token, | ||||
| setToken, | |||||
| userInfo, | userInfo, | ||||
| isLogin, | isLogin, | ||||
| showLogin, | showLogin, | ||||
| setLogin, | setLogin, | ||||
| logout, | logout, | ||||
| refreshUserInfo, | refreshUserInfo, | ||||
| refreshBalance, | |||||
| handleAuthError, | handleAuthError, | ||||
| init, | init, | ||||
| } | } | ||||
| @@ -1,72 +1,292 @@ | |||||
| <template> | <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 class="flex min-h-screen flex-col bg-[var(--c-f3f7fb)]"> | |||||
| <!-- ==================== 顶部导航 ==================== --> | |||||
| <header class="sticky top-0 z-50 border-b border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/85 backdrop-blur-md"> | |||||
| <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> | |||||
| <div class="flex h-16 items-center gap-4"> | |||||
| <!-- Logo + Slogan --> | |||||
| <NuxtLink to="/" class="flex shrink-0 items-center gap-2.5"> | |||||
| <img | |||||
| src="/logo.png" | |||||
| alt="AI在线" | |||||
| class="h-9 w-9 rounded-xl shadow-[0_6px_16px_var(--sh-11-140-255-220)]" | |||||
| > | |||||
| <div class="hidden leading-tight sm:block"> | |||||
| <div class="text-[17px] font-extrabold tracking-tight text-[var(--c-172033)]">AI 在线</div> | |||||
| <div class="text-[11px] font-medium text-[var(--c-8a97a8)]">解放你的生产力</div> | |||||
| </div> | </div> | ||||
| <span class="text-lg font-semibold text-slate-800 hidden sm:block">奇想宇宙</span> | |||||
| </NuxtLink> | </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> | |||||
| <!-- 主导航(AI 绘画为一级入口,hover 展开二级菜单,直接点击进动漫头像) --> | |||||
| <nav class="ml-2 hidden items-center gap-1 md:flex"> | |||||
| <template v-for="item in navItems" :key="item.label"> | |||||
| <div | |||||
| v-if="item.children" | |||||
| class="relative" | |||||
| @mouseenter="drawMenuOpen = true" | |||||
| @mouseleave="drawMenuOpen = false" | |||||
| @focusin="drawMenuOpen = true" | |||||
| @focusout="onDrawMenuFocusOut" | |||||
| > | |||||
| <NuxtLink | |||||
| :to="item.to" | |||||
| class="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]" | |||||
| :class="isGroupActive(item) | |||||
| ? 'bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]' | |||||
| : 'text-[var(--c-5b6b80)]'" | |||||
| :aria-expanded="drawMenuOpen" | |||||
| > | |||||
| {{ item.label }} | |||||
| <UIcon | |||||
| name="i-lucide-chevron-down" | |||||
| class="size-3.5 transition-transform" | |||||
| :class="drawMenuOpen && 'rotate-180'" | |||||
| /> | |||||
| </NuxtLink> | |||||
| <Transition name="menu"> | |||||
| <!-- 外层留 pt-2 把触发区和面板连起来,避免鼠标移动时菜单闪退 --> | |||||
| <div v-if="drawMenuOpen" class="absolute left-0 top-full z-50 pt-2"> | |||||
| <div class="w-64 overflow-hidden rounded-xl border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] py-1.5 shadow-[0_12px_32px_var(--sh-23-32-51-140)]"> | |||||
| <NuxtLink | |||||
| v-for="child in item.children" | |||||
| :key="child.to" | |||||
| :to="child.to" | |||||
| class="block px-4 py-2.5 transition-colors hover:bg-[var(--c-f7fbff)]" | |||||
| active-class="bg-[var(--c-f7fbff)]" | |||||
| @click="drawMenuOpen = false" | |||||
| > | |||||
| <span class="block text-sm font-bold text-[var(--c-172033)]">{{ child.label }}</span> | |||||
| <span class="mt-0.5 block text-xs text-[var(--c-8a97a8)]">{{ child.desc }}</span> | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </div> | |||||
| </Transition> | |||||
| </div> | |||||
| <NuxtLink | |||||
| v-else | |||||
| :to="item.to" | |||||
| class="rounded-lg px-3 py-2 text-sm font-medium text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]" | |||||
| active-class="!text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]" | |||||
| > | |||||
| {{ item.label }} | |||||
| </NuxtLink> | |||||
| </template> | |||||
| </nav> | </nav> | ||||
| <!-- 右侧操作 --> | <!-- 右侧操作 --> | ||||
| <div class="flex items-center gap-3"> | |||||
| <div class="ml-auto flex items-center gap-2 sm:gap-3"> | |||||
| <!-- 亮色 / 暗色切换 --> | |||||
| <ThemeToggle /> | |||||
| <template v-if="isLogin"> | <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="/bill" | |||||
| class="hidden items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-3 py-1.5 text-xs font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)] sm:flex" | |||||
| > | |||||
| <UIcon name="i-lucide-coins" class="size-3.5" /> | |||||
| <span>{{ userInfo.balance || 0 }} 点</span> | |||||
| </NuxtLink> | |||||
| <NuxtLink | <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" | |||||
| to="/recharge" | |||||
| class="hidden rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] px-4 py-1.5 text-xs font-bold text-white shadow-[0_6px_14px_var(--sh-11-140-255-250)] transition-opacity hover:opacity-90 sm:block" | |||||
| > | > | ||||
| 我的作品 | |||||
| 充值 | |||||
| </NuxtLink> | </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 ref="userMenuRef" class="relative"> | |||||
| <button | |||||
| aria-label="用户菜单" | |||||
| :aria-expanded="userMenuOpen" | |||||
| class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full bg-[var(--c-eef7ff)] ring-1 ring-[var(--c-dbeafe)] transition-shadow hover:ring-[var(--c-0b8cff)]/40" | |||||
| @click="userMenuOpen = !userMenuOpen" | |||||
| > | |||||
| <img v-if="userInfo.avatar" :src="userInfo.avatar" class="h-full w-full object-cover" alt="avatar"> | |||||
| <span v-else class="text-xs font-extrabold text-[var(--c-0b8cff)]"> | |||||
| {{ (userInfo.nickname || userInfo.user_name || '用')[0] }} | |||||
| </span> | |||||
| </button> | |||||
| <Transition name="menu"> | |||||
| <div | |||||
| v-if="userMenuOpen" | |||||
| class="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] py-1.5 shadow-[0_12px_32px_var(--sh-23-32-51-140)]" | |||||
| > | |||||
| <!-- 用户信息 --> | |||||
| <div class="border-b border-[var(--c-f1f5fa)] px-4 pb-2.5 pt-1.5"> | |||||
| <div class="truncate text-sm font-bold text-[var(--c-172033)]"> | |||||
| {{ userInfo.nickname || userInfo.user_name || '我的账号' }} | |||||
| </div> | |||||
| <div class="mt-0.5 text-xs text-[var(--c-8a97a8)]">余额 {{ userInfo.balance || 0 }} 点</div> | |||||
| </div> | |||||
| <NuxtLink | |||||
| v-for="item in userMenuLinks" | |||||
| :key="item.to" | |||||
| :to="item.to" | |||||
| class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--c-4b5b70)] transition-colors hover:bg-[var(--c-f7fbff)] hover:text-[var(--c-0b8cff)]" | |||||
| @click="userMenuOpen = false" | |||||
| > | |||||
| <UIcon :name="item.icon" class="size-4 shrink-0" /> | |||||
| <span>{{ item.label }}</span> | |||||
| </NuxtLink> | |||||
| <div class="my-1 border-t border-[var(--c-f1f5fa)]" /> | |||||
| <button | |||||
| class="flex w-full items-center gap-2.5 px-4 py-2.5 text-left text-sm text-[var(--c-f04438)] transition-colors hover:bg-[var(--c-fff5f5)]" | |||||
| @click="onLogout" | |||||
| > | |||||
| <UIcon name="i-lucide-log-out" class="size-4 shrink-0" /> | |||||
| <span>退出登录</span> | |||||
| </button> | |||||
| </div> | |||||
| </Transition> | |||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| <button | <button | ||||
| v-else | 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" | |||||
| class="cursor-pointer rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] px-5 py-2 text-sm font-bold text-white shadow-[0_8px_18px_var(--sh-11-140-255-250)] transition-opacity hover:opacity-90" | |||||
| @click="showLogin = true" | @click="showLogin = true" | ||||
| > | > | ||||
| 登录 | 登录 | ||||
| </button> | </button> | ||||
| <!-- 移动端菜单按钮 --> | |||||
| <button | |||||
| class="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-eef7ff)] md:hidden" | |||||
| @click="mobileOpen = !mobileOpen" | |||||
| > | |||||
| <UIcon :name="mobileOpen ? 'i-lucide-x' : 'i-lucide-menu'" class="size-5" /> | |||||
| </button> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <!-- 移动端下拉导航 --> | |||||
| <Transition name="slide"> | |||||
| <nav v-if="mobileOpen" class="border-t border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] md:hidden"> | |||||
| <div class="mx-auto max-w-7xl px-4 py-2 sm:px-6"> | |||||
| <template v-for="item in navItems" :key="item.label"> | |||||
| <!-- 移动端没有 hover,二级菜单直接平铺缩进展示 --> | |||||
| <div v-if="item.children"> | |||||
| <div class="px-3 pb-1 pt-2.5 text-xs font-bold text-[var(--c-8a97a8)]">{{ item.label }}</div> | |||||
| <NuxtLink | |||||
| v-for="child in item.children" | |||||
| :key="child.to" | |||||
| :to="child.to" | |||||
| class="block rounded-lg px-3 py-2.5 pl-6 text-sm font-medium text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]" | |||||
| active-class="!text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]" | |||||
| @click="mobileOpen = false" | |||||
| > | |||||
| {{ child.label }} | |||||
| </NuxtLink> | |||||
| </div> | |||||
| <NuxtLink | |||||
| v-else | |||||
| :to="item.to" | |||||
| class="block rounded-lg px-3 py-2.5 text-sm font-medium text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]" | |||||
| active-class="!text-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]" | |||||
| @click="mobileOpen = false" | |||||
| > | |||||
| {{ item.label }} | |||||
| </NuxtLink> | |||||
| </template> | |||||
| </div> | |||||
| </nav> | |||||
| </Transition> | |||||
| </header> | </header> | ||||
| <!-- 主体内容 --> | |||||
| <!-- ==================== 主体内容 ==================== --> | |||||
| <main class="flex-1"> | <main class="flex-1"> | ||||
| <slot /> | <slot /> | ||||
| </main> | </main> | ||||
| <!-- 页脚 --> | |||||
| <footer class="border-t border-slate-200 bg-white py-8 mt-auto"> | |||||
| <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm text-slate-400"> | |||||
| <p>© {{ new Date().getFullYear() }} 奇想宇宙 AIonline. All rights reserved.</p> | |||||
| <!-- ==================== 页脚(白色系、紧凑,二维码与内容同行) ==================== --> | |||||
| <footer class="mt-auto border-t border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]"> | |||||
| <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> | |||||
| <div class="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between"> | |||||
| <!-- 品牌 --> | |||||
| <div class="max-w-xs"> | |||||
| <div class="flex items-center gap-2.5"> | |||||
| <img src="/logo.png" alt="AI在线" class="h-8 w-8 rounded-lg"> | |||||
| <span class="text-base font-extrabold text-[var(--c-172033)]">AI 在线</span> | |||||
| </div> | |||||
| <p class="mt-2.5 text-sm leading-relaxed text-[var(--c-8a97a8)]"> | |||||
| 解放你的生产力。AI 取名、动漫头像、证件照,一站式智能创作平台。 | |||||
| </p> | |||||
| </div> | |||||
| <!-- 链接 + 二维码同行 --> | |||||
| <div class="flex flex-wrap items-start gap-10 sm:gap-14"> | |||||
| <div> | |||||
| <div class="mb-2.5 text-sm font-bold text-[var(--c-172033)]">产品</div> | |||||
| <ul class="space-y-2 text-sm text-[var(--c-8a97a8)]"> | |||||
| <li v-for="item in productLinks" :key="item.to"> | |||||
| <NuxtLink :to="item.to" class="transition-colors hover:text-[var(--c-0b8cff)]">{{ item.label }}</NuxtLink> | |||||
| </li> | |||||
| </ul> | |||||
| </div> | |||||
| <div> | |||||
| <div class="mb-2.5 text-sm font-bold text-[var(--c-172033)]">资讯</div> | |||||
| <ul class="space-y-2 text-sm text-[var(--c-8a97a8)]"> | |||||
| <li v-for="item in infoLinks" :key="item.to"> | |||||
| <NuxtLink :to="item.to" class="transition-colors hover:text-[var(--c-0b8cff)]">{{ item.label }}</NuxtLink> | |||||
| </li> | |||||
| </ul> | |||||
| </div> | |||||
| <div> | |||||
| <div class="mb-2.5 text-sm font-bold text-[var(--c-172033)]">我的</div> | |||||
| <ul class="space-y-2 text-sm text-[var(--c-8a97a8)]"> | |||||
| <li><NuxtLink to="/works" class="transition-colors hover:text-[var(--c-0b8cff)]">我的作品</NuxtLink></li> | |||||
| <li v-if="earnEnabled"><NuxtLink to="/earn-points" class="transition-colors hover:text-[var(--c-0b8cff)]">获得点数</NuxtLink></li> | |||||
| <li v-if="inviteEnabled"><NuxtLink to="/invite" class="transition-colors hover:text-[var(--c-0b8cff)]">邀请好友</NuxtLink></li> | |||||
| <li><NuxtLink to="/bill" class="transition-colors hover:text-[var(--c-0b8cff)]">账单明细</NuxtLink></li> | |||||
| <li><NuxtLink to="/recharge" class="transition-colors hover:text-[var(--c-0b8cff)]">充值中心</NuxtLink></li> | |||||
| </ul> | |||||
| </div> | |||||
| <!-- 公众号二维码 --> | |||||
| <div class="flex flex-col items-center"> | |||||
| <img | |||||
| src="https://cdn.aionline.cc/ai/aionline_qr.jpg" | |||||
| alt="AI在线公众号" | |||||
| class="h-20 w-20 rounded-lg border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] p-1" | |||||
| loading="lazy" | |||||
| > | |||||
| <div class="mt-2 text-xs text-[var(--c-8a97a8)]">扫码关注我们</div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 备案与协议(对齐 pc 站点) --> | |||||
| <div class="mt-6 flex flex-col items-center gap-2 border-t border-[var(--c-eef2f7)] pt-5 text-xs text-[var(--c-9aa5b5)] sm:flex-row sm:justify-center sm:gap-5"> | |||||
| <NuxtLink to="/terms" class="transition-colors hover:text-[var(--c-0b8cff)]">服务条款</NuxtLink> | |||||
| <NuxtLink to="/privacy" class="transition-colors hover:text-[var(--c-0b8cff)]">隐私政策</NuxtLink> | |||||
| <span>四川内宇科技有限公司</span> | |||||
| <a | |||||
| href="https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=51010702042692" | |||||
| target="_blank" | |||||
| rel="noopener" | |||||
| class="transition-colors hover:text-[var(--c-0b8cff)]" | |||||
| >川公网安备51010702042692号</a> | |||||
| <a | |||||
| href="https://beian.miit.gov.cn/#/Integrated/index" | |||||
| target="_blank" | |||||
| rel="noopener" | |||||
| class="transition-colors hover:text-[var(--c-0b8cff)]" | |||||
| >蜀ICP备2023026929号-1</a> | |||||
| <span>© {{ new Date().getFullYear() }} AI在线</span> | |||||
| </div> | |||||
| </div> | </div> | ||||
| </footer> | </footer> | ||||
| <!-- 全局回到顶部(所有页面共用,滚动超过一屏才出现) --> | |||||
| <BackToTop /> | |||||
| <!-- 登录弹窗 --> | <!-- 登录弹窗 --> | ||||
| <LoginModal | <LoginModal | ||||
| v-if="showLogin" | v-if="showLogin" | ||||
| @@ -77,17 +297,141 @@ | |||||
| </template> | </template> | ||||
| <script setup> | <script setup> | ||||
| // 顶部导航。图像类能力收进「AI 绘画」二级菜单,一级入口本身指向动漫头像。 | |||||
| const drawChildren = [ | |||||
| { label: '动漫头像', to: '/draw/anime/', desc: '自拍一键变动漫风头像' }, | |||||
| { label: '形象照 / 写真', to: '/portrait-photo', desc: '商务、职业照与艺术写真' }, | |||||
| { label: '老照片修复', to: '/old-photo', desc: '修复破损并自然上色' }, | |||||
| { label: 'AI 证件照', to: '/id-photo', desc: '换底色、标准尺寸' }, | |||||
| ] | |||||
| const navItems = [ | const navItems = [ | ||||
| { label: '首页', to: '/' }, | { label: '首页', to: '/' }, | ||||
| { label: 'AI 取名', to: '/ai-name' }, | |||||
| { label: '动漫头像', to: '/anime-avatar' }, | |||||
| { label: '充值', to: '/recharge' }, | |||||
| { label: 'AI 取名', to: '/writing/naming/' }, | |||||
| { label: 'AI 绘画', to: '/draw/anime/', children: drawChildren }, | |||||
| { label: 'AI 资讯', to: '/info' }, | |||||
| { label: '我的作品', to: '/works' }, | |||||
| ] | |||||
| // 注:AI 图片(/pic)页面保留,只是不放顶部导航和页脚入口 | |||||
| // 页脚「产品」列:只放真正的产品能力,个人相关的放「我的」列 | |||||
| const productLinks = [ | |||||
| { label: 'AI 取名', to: '/writing/naming/' }, | |||||
| { label: '动漫头像', to: '/draw/anime/' }, | |||||
| { label: '形象照 / 写真', to: '/portrait-photo' }, | |||||
| { label: '老照片修复', to: '/old-photo' }, | |||||
| { label: 'AI 证件照', to: '/id-photo' }, | |||||
| ] | |||||
| // 页脚「资讯」列 | |||||
| const infoLinks = [ | |||||
| { label: 'AI 资讯', to: '/info' }, | |||||
| { label: '前沿资讯', to: '/info/new' }, | |||||
| { label: '使用教程', to: '/info/course' }, | |||||
| { label: 'AI 问答', to: '/info/what' }, | |||||
| ] | |||||
| const { userInfo, isLogin, showLogin, setLogin, logout, refreshBalance } = useUser() | |||||
| const { post } = useApi() | |||||
| const mobileOpen = ref(false) | |||||
| const userMenuOpen = ref(false) | |||||
| const drawMenuOpen = ref(false) | |||||
| const userMenuRef = ref(null) | |||||
| const route = useRoute() | |||||
| /** 二级菜单里任一页面命中时,一级「AI 绘画」也保持选中态 */ | |||||
| function isGroupActive(item) { | |||||
| const path = route.path.replace(/\/+$/, '') || '/' | |||||
| return (item.children || []).some((child) => { | |||||
| const target = child.to.replace(/\/+$/, '') || '/' | |||||
| return path === target || path.startsWith(`${target}/`) | |||||
| }) | |||||
| } | |||||
| /** 焦点移出整个下拉区域才收起,键盘 Tab 浏览时菜单不会跳掉 */ | |||||
| function onDrawMenuFocusOut(event) { | |||||
| if (!event.currentTarget.contains(event.relatedTarget)) drawMenuOpen.value = false | |||||
| } | |||||
| // 后台未启用邀请活动时,入口默认不渲染;请求失败同样按未启用处理。 | |||||
| const { data: inviteConfigResponse } = await useAsyncData( | |||||
| 'global-invite-entry-config', | |||||
| () => post('/invite/config'), | |||||
| ) | |||||
| const inviteEnabled = computed( | |||||
| () => Number(inviteConfigResponse.value?.data?.config?.status) === 1, | |||||
| ) | |||||
| // 获得点数:签到、邀请与看广告任一开启才有入口,全部关闭时整条不渲染 | |||||
| const { entryEnabled: earnEnabled, load: loadEarnConfig } = useEarnPoints() | |||||
| await loadEarnConfig() | |||||
| const allUserMenuLinks = [ | |||||
| { label: '我的作品', icon: 'i-lucide-image', to: '/works' }, | |||||
| { label: '获得点数', icon: 'i-lucide-gift', to: '/earn-points', earnOnly: true }, | |||||
| { label: '邀请好友', icon: 'i-lucide-user-plus', to: '/invite', inviteOnly: true }, | |||||
| { label: '账单明细', icon: 'i-lucide-receipt', to: '/bill' }, | |||||
| { label: '充值中心', icon: 'i-lucide-coins', to: '/recharge' }, | |||||
| ] | ] | ||||
| const userMenuLinks = computed( | |||||
| () => allUserMenuLinks.filter((item) => { | |||||
| if (item.inviteOnly && !inviteEnabled.value) return false | |||||
| if (item.earnOnly && !earnEnabled.value) return false | |||||
| return true | |||||
| }), | |||||
| ) | |||||
| // 路由切换时收起菜单 | |||||
| watch(() => route.fullPath, () => { | |||||
| mobileOpen.value = false | |||||
| userMenuOpen.value = false | |||||
| drawMenuOpen.value = false | |||||
| }) | |||||
| // 点击外部关闭用户菜单 | |||||
| function onDocClick(e) { | |||||
| if (userMenuOpen.value && userMenuRef.value && !userMenuRef.value.contains(e.target)) { | |||||
| userMenuOpen.value = false | |||||
| } | |||||
| } | |||||
| onMounted(() => { | |||||
| document.addEventListener('click', onDocClick) | |||||
| // 已登录时刷新点数,保证 header 显示的是最新余额 | |||||
| if (isLogin.value) refreshBalance() | |||||
| }) | |||||
| onUnmounted(() => document.removeEventListener('click', onDocClick)) | |||||
| const { userInfo, isLogin, showLogin, setLogin } = useUser() | |||||
| // 登录状态变化时同步点数 | |||||
| watch(isLogin, (v) => { if (v) refreshBalance() }) | |||||
| function onLogout() { | |||||
| userMenuOpen.value = false | |||||
| logout() | |||||
| } | |||||
| function onLoginSuccess(data) { | function onLoginSuccess(data) { | ||||
| setLogin(data) | setLogin(data) | ||||
| showLogin.value = false | showLogin.value = false | ||||
| refreshBalance() | |||||
| } | } | ||||
| </script> | </script> | ||||
| <style scoped> | |||||
| .slide-enter-active, | |||||
| .slide-leave-active { | |||||
| transition: opacity 0.18s ease; | |||||
| } | |||||
| .slide-enter-from, | |||||
| .slide-leave-to { | |||||
| opacity: 0; | |||||
| } | |||||
| .menu-enter-active, | |||||
| .menu-leave-active { | |||||
| transition: opacity 0.15s ease, transform 0.15s ease; | |||||
| } | |||||
| .menu-enter-from, | |||||
| .menu-leave-to { | |||||
| opacity: 0; | |||||
| transform: translateY(-4px); | |||||
| } | |||||
| </style> | |||||
| @@ -0,0 +1,4 @@ | |||||
| export default defineNuxtRouteMiddleware((to) => { | |||||
| const { captureInvite } = useInvite() | |||||
| captureInvite(to.query) | |||||
| }) | |||||
| @@ -0,0 +1,22 @@ | |||||
| /** | |||||
| * 将重构期间出现的不带 .html 的详情地址收敛到旧站已收录 URL。 | |||||
| * 只做 URL 规范化,不参与任何页面业务逻辑。 | |||||
| */ | |||||
| export default defineNuxtRouteMiddleware((to) => { | |||||
| const path = to.path | |||||
| if (path.endsWith('.html')) return | |||||
| const needsHtmlSuffix = [ | |||||
| /^\/pic\/[^/]+$/, | |||||
| /^\/info\/(?:new|course|what)\/[^/]+$/, | |||||
| /^\/info\/tag\/[^/]+$/, | |||||
| /^\/(?:news|baike|course)\/detail\/[^/]+$/, | |||||
| ].some(pattern => pattern.test(path)) | |||||
| if (!needsHtmlSuffix) return | |||||
| return navigateTo( | |||||
| { path: `${path}.html`, query: to.query, hash: to.hash }, | |||||
| { redirectCode: 301, replace: true }, | |||||
| ) | |||||
| }) | |||||
| @@ -1,724 +0,0 @@ | |||||
| <template> | |||||
| <div class="min-h-screen bg-[#f3f7fb]"> | |||||
| <!-- ========== Hero (对齐 V2 渐变风格) ========== --> | |||||
| <div class="relative overflow-hidden text-white" | |||||
| style="background: linear-gradient(135deg, #138cff 0%, #34c3ff 54%, #79e0df 100%)"> | |||||
| <div class="absolute w-[360px] h-[360px] rounded-full bg-white/20 -right-[110px] -top-[140px]" /> | |||||
| <div class="absolute w-[240px] h-[240px] rounded-full bg-white/15 right-[86px] -bottom-[130px]" /> | |||||
| <div class="relative max-w-7xl mx-auto px-4 sm:px-6 py-8 lg:py-10"> | |||||
| <div class="lg:flex lg:items-end lg:justify-between gap-8"> | |||||
| <div class="max-w-[460px]"> | |||||
| <div class="text-[11px] lg:text-xs font-bold tracking-wider text-white/75">AI NAME STUDIO</div> | |||||
| <h1 class="mt-3 text-[26px] lg:text-[36px] font-extrabold leading-tight">给宝宝取一个有出处的好名字</h1> | |||||
| <p class="mt-3 text-sm lg:text-base text-white/90 leading-relaxed">结合姓氏、性别、诗词典故和音律寓意,生成可直接挑选的名字方案。</p> | |||||
| </div> | |||||
| <div class="hidden lg:block w-[260px] flex-shrink-0"> | |||||
| <div class="rounded-3xl border border-white/40 bg-white/20 backdrop-blur p-5 shadow-[0_18px_34px_rgba(0,92,190,0.16)]"> | |||||
| <div class="text-3xl font-extrabold">{{ previewName }}</div> | |||||
| <div class="mt-1.5 text-xs text-white/85">李白《独坐敬亭山》</div> | |||||
| </div> | |||||
| <div class="mt-3 h-12 rounded-2xl border border-white/40 bg-white/20 backdrop-blur flex items-center justify-between px-4 text-xs text-white/90"> | |||||
| <span>音律</span><span>寓意</span><span>出处</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== Main Grid ========== --> | |||||
| <div class="max-w-7xl mx-auto px-4 sm:px-6 py-6 lg:py-8"> | |||||
| <div class="lg:grid lg:grid-cols-12 lg:gap-8"> | |||||
| <!-- ===== LEFT: Form (col-span-7) ===== --> | |||||
| <div class="lg:col-span-7 space-y-5 pb-24 lg:pb-0"> | |||||
| <!-- 1. 基础信息 Card --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_rgba(34,68,112,0.05)]"> | |||||
| <div class="flex items-baseline mb-4"> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">基础信息</h3> | |||||
| <span class="ml-auto text-xs text-[#8a95a6]">必填</span> | |||||
| </div> | |||||
| <!-- Surname + Gender (一行,对齐 V2 surname-box) --> | |||||
| <div class="flex items-stretch gap-3 rounded-2xl bg-[#f6f9fe] p-3.5"> | |||||
| <div class="flex-1 min-w-0"> | |||||
| <label class="block text-sm font-bold text-[#172033]">姓氏</label> | |||||
| <input v-model="form.surname" type="text" maxlength="5" placeholder="输入姓氏" | |||||
| class="w-full mt-1 bg-transparent text-xl lg:text-2xl font-extrabold text-[#172033] placeholder:text-[#a0aabb] focus:outline-none" /> | |||||
| </div> | |||||
| <div class="w-[180px] flex-shrink-0 p-1.5 rounded-[28px] bg-white shadow-[inset_0_0_0_1px_#edf2f7] flex"> | |||||
| <button v-for="opt in genderOptions" :key="opt.value" type="button" | |||||
| class="flex-1 h-10 rounded-[24px] text-sm font-bold transition-all" | |||||
| :class="form.sex === opt.value ? 'bg-[#0b8cff] text-white' : 'text-[#7f8896]'" | |||||
| @click="form.sex = opt.value">{{ opt.label }}</button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 出生信息开关 --> | |||||
| <div class="mt-4 rounded-2xl border-2 transition-colors" | |||||
| :class="birthInfoIgnored ? 'bg-[#f7f9fd] border-[#edf2f7]' : 'bg-[#eff8ff] border-[#0b8cff]/20'"> | |||||
| <div class="flex items-center p-4 cursor-pointer" @click="toggleBirthInfo"> | |||||
| <div class="flex-1 min-w-0"> | |||||
| <div class="text-sm font-extrabold text-[#172033]">参考出生信息</div> | |||||
| <div class="mt-1 text-xs text-[#7f8896]">{{ birthInfoIgnored ? '已关闭,生成时不参考农历和五行' : '开启后会结合农历和五行倾向' }}</div> | |||||
| </div> | |||||
| <div class="ml-3 w-[60px] h-7 p-1 rounded-full transition-colors flex-shrink-0" | |||||
| :class="!birthInfoIgnored ? 'bg-[#0b8cff]' : 'bg-[#d7deea]'"> | |||||
| <div class="w-5 h-5 rounded-full bg-white shadow transition-transform" | |||||
| :class="!birthInfoIgnored && 'translate-x-7'" /> | |||||
| </div> | |||||
| </div> | |||||
| <div v-if="!birthInfoIgnored" class="px-4 pb-4 space-y-3"> | |||||
| <div class="grid grid-cols-2 gap-3"> | |||||
| <div class="rounded-2xl bg-[#f6f9fe] p-4 flex items-start justify-between cursor-pointer min-h-[88px]" | |||||
| @click="openDatePicker"> | |||||
| <div class="min-w-0"> | |||||
| <div class="text-sm font-bold text-[#172033]">出生日期</div> | |||||
| <div class="mt-2 text-sm text-[#7f8896]">{{ birthDate || '请选择日期' }}</div> | |||||
| </div> | |||||
| <span class="ml-2 text-2xl text-[#a0aabb] leading-none flex-shrink-0">›</span> | |||||
| </div> | |||||
| <div class="rounded-2xl bg-[#f6f9fe] p-4 flex items-start justify-between cursor-pointer min-h-[88px]" | |||||
| :class="{ 'opacity-80': birthTimeUnknown }" @click="openTimePicker"> | |||||
| <div class="min-w-0"> | |||||
| <div class="text-sm font-bold text-[#172033]">出生时分</div> | |||||
| <div class="mt-2 text-sm text-[#7f8896]">{{ birthClockText }}</div> | |||||
| </div> | |||||
| <span class="ml-2 text-2xl text-[#a0aabb] leading-none flex-shrink-0">›</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 农历显示 --> | |||||
| <div v-if="birthLunarText" class="rounded-2xl bg-[#eff8ff] p-4 flex items-center"> | |||||
| <span class="mr-3 px-3 py-0.5 rounded-full bg-white text-[#0b8cff] text-xs font-extrabold flex-shrink-0">农历</span> | |||||
| <span class="text-sm font-bold text-[#3a5878] min-w-0">{{ birthLunarText }}</span> | |||||
| </div> | |||||
| <!-- 不知道具体时间 --> | |||||
| <div class="inline-flex items-center rounded-full px-4 py-2 cursor-pointer transition-colors border-2" | |||||
| :class="birthTimeUnknown ? 'bg-[#eff8ff] border-[#0b8cff] text-[#0b8cff]' : 'bg-[#f6f9fe] border-transparent text-[#7f8896]'" | |||||
| @click="toggleBirthTimeUnknown"> | |||||
| <span class="w-1.5 h-1.5 mr-2 rounded-full bg-current" /> | |||||
| <span class="text-xs font-bold">不知道具体时间</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 2. 名字出处 Card --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_rgba(34,68,112,0.05)]"> | |||||
| <div class="flex items-baseline"> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">名字出处</h3> | |||||
| <span class="ml-auto text-xs text-[#8a95a6]">选择偏好的文化来源</span> | |||||
| </div> | |||||
| <div class="mt-4 grid grid-cols-2 lg:grid-cols-3 gap-2.5"> | |||||
| <button v-for="src in visibleSourceOptions" :key="src.id" type="button" | |||||
| class="min-h-[72px] p-3 rounded-2xl border-2 transition-all text-left" | |||||
| :class="form.name_source === src.id | |||||
| ? 'bg-[#eff8ff] border-[#0b8cff] shadow-[0_8px_20px_rgba(11,140,255,0.1)]' | |||||
| : 'bg-[#f7f9fd] border-transparent hover:border-[#e7edf5]'" | |||||
| @click="form.name_source = src.id"> | |||||
| <div class="text-sm font-extrabold text-[#172033] text-center">{{ src.label }}</div> | |||||
| <div class="mt-1 text-[11px] text-[#8a95a6] text-center">{{ src.desc }}</div> | |||||
| </button> | |||||
| <button type="button" | |||||
| class="min-h-[72px] p-3 rounded-2xl border-2 bg-white border-[#e7edf5] hover:border-[#0b8cff]/30 transition-all text-left" | |||||
| @click="sourceExpanded = !sourceExpanded"> | |||||
| <div class="text-sm font-extrabold text-[#172033] text-center">{{ sourceExpanded ? '收起' : '更多' }}</div> | |||||
| <div class="mt-1 text-[11px] text-[#8a95a6] text-center">{{ sourceExpanded ? '精简显示' : '展开全部' }}</div> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 3. 名字偏好 Card --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_rgba(34,68,112,0.05)]"> | |||||
| <div class="flex items-baseline"> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">名字偏好</h3> | |||||
| <span class="ml-auto text-xs text-[#8a95a6]">用于控制生成结果的方向</span> | |||||
| </div> | |||||
| <!-- 字数 --> | |||||
| <div class="mt-5"> | |||||
| <div class="text-sm font-bold text-[#172033]">名字字数 <span class="text-xs font-normal text-[#8a95a6]">包含姓氏</span></div> | |||||
| <div class="mt-3 flex flex-wrap gap-2.5"> | |||||
| <button v-for="wc in wordOptions" :key="wc.id" type="button" | |||||
| class="h-10 px-5 rounded-full text-sm font-bold transition-all border-2" | |||||
| :class="form.words_num === wc.id ? 'bg-[#eff8ff] border-[#0b8cff] text-[#0b8cff]' : 'bg-[#f6f9fe] border-transparent text-[#596579]'" | |||||
| @click="form.words_num = wc.id">{{ wc.label }}</button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 是否叠词 --> | |||||
| <div class="mt-5"> | |||||
| <div class="text-sm font-bold text-[#172033]">是否叠词</div> | |||||
| <div class="mt-3 flex flex-wrap gap-2.5"> | |||||
| <button v-for="r in redupOptions" :key="r.id" type="button" | |||||
| class="h-10 px-5 rounded-full text-sm font-bold transition-all border-2" | |||||
| :class="form.is_redup === r.id ? 'bg-[#eff8ff] border-[#0b8cff] text-[#0b8cff]' : 'bg-[#f6f9fe] border-transparent text-[#596579]'" | |||||
| @click="form.is_redup = r.id">{{ r.label }}</button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 指定包含字 --> | |||||
| <div class="mt-5 flex items-center rounded-2xl bg-[#f7f9fd] p-4"> | |||||
| <div class="min-w-0"> | |||||
| <div class="text-sm font-bold text-[#172033]">指定包含字</div> | |||||
| <div class="mt-1 text-xs text-[#8a95a6]">可选,限制 1 个字</div> | |||||
| </div> | |||||
| <input v-model="form.special_word" type="text" maxlength="1" placeholder="如:辰" | |||||
| class="ml-auto w-24 h-12 rounded-2xl bg-white text-center text-lg font-extrabold text-[#172033] placeholder:text-[#a0aabb] focus:outline-none focus:ring-2 focus:ring-[#0b8cff]/30" /> | |||||
| </div> | |||||
| <!-- 补充描述 --> | |||||
| <div class="mt-4 rounded-2xl bg-[#f7f9fd] p-4"> | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <div> | |||||
| <div class="text-sm font-bold text-[#172033]">补充描述</div> | |||||
| <div class="mt-1 text-xs text-[#8a95a6]">额外要求、寓意和期望</div> | |||||
| </div> | |||||
| <div class="text-xs text-[#9aa5b5] flex-shrink-0">{{ (form.content || '').length }}/200</div> | |||||
| </div> | |||||
| <textarea v-model="form.content" rows="3" maxlength="200" placeholder="您可以补充您的额外要求,也可以添加您的寓意和期望。200字以内..." | |||||
| class="mt-3 w-full bg-transparent text-sm text-[#172033] placeholder:text-[#a0aabb] focus:outline-none resize-none leading-relaxed"></textarea> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 4. Promise Card --> | |||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_rgba(34,68,112,0.05)] space-y-4"> | |||||
| <div class="flex items-center"> | |||||
| <span class="w-7 h-5 mr-3 rounded-[14px] bg-[#eef6ff] text-[#0b8cff] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">01</span> | |||||
| <span class="text-sm text-[#596579] leading-relaxed">可关闭出生信息参考,关闭后不结合农历和五行</span> | |||||
| </div> | |||||
| <div class="flex items-center"> | |||||
| <span class="w-7 h-5 mr-3 rounded-[14px] bg-[#eef6ff] text-[#0b8cff] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">02</span> | |||||
| <span class="text-sm text-[#596579] leading-relaxed">开启后可选择日期和时分,不确定时辰可选"不知道具体时间"</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- Desktop Submit --> | |||||
| <div class="hidden lg:flex items-center gap-3 rounded-2xl bg-white p-4 shadow-[0_8px_28px_rgba(34,68,112,0.05)]"> | |||||
| <div class="flex-1 min-w-0"> | |||||
| <div class="text-sm font-bold text-[#172033] whitespace-nowrap">{{ balanceText }}</div> | |||||
| <div v-if="isBalanceInsufficient" class="mt-1 text-xs text-[#f04438]">点数不足,请前往充值</div> | |||||
| </div> | |||||
| <button type="button" | |||||
| class="px-10 h-14 rounded-full bg-[#0b8cff] hover:bg-[#0a7ce0] active:bg-[#096dca] text-white font-bold text-base shadow-[0_12px_24px_rgba(11,140,255,0.25)] transition-all flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-w-[160px]" | |||||
| :disabled="!canSubmit || submitting" @click="handleSubmit"> | |||||
| <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" /> | |||||
| <span>{{ submitting ? '生成中...' : submitText }}</span> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ===== RIGHT: Result (col-span-5, sticky) ===== --> | |||||
| <div class="lg:col-span-5"> | |||||
| <div class="lg:sticky lg:top-6 space-y-4"> | |||||
| <div v-if="!resultState.visible" class="bg-white rounded-2xl p-8 lg:p-10 shadow-sm text-center min-h-[300px] flex flex-col items-center justify-center"> | |||||
| <UIcon name="i-lucide-sparkles" class="size-12 text-[#a0aabb] mb-4" /> | |||||
| <div class="text-base font-medium text-[#172033] mb-1">等待生成</div> | |||||
| <div class="text-sm text-[#a0aabb]">填写左侧信息并点击"开始取名"</div> | |||||
| <div class="text-sm text-[#a0aabb]">AI将为您推荐最佳名字方案</div> | |||||
| </div> | |||||
| <div v-if="resultState.loading" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm"> | |||||
| <div class="text-base font-bold text-[#172033] mb-1">{{ questionTitle }}</div> | |||||
| <div class="text-sm text-[#7f8896] mb-6">AI 正在分析音律、寓意和出处...</div> | |||||
| <div class="rounded-2xl bg-[#f7f9fd] py-10 flex flex-col items-center"> | |||||
| <div class="flex gap-1.5 mb-4"> | |||||
| <span class="w-2.5 h-2.5 rounded-full bg-[#0b8cff] animate-pulse-dot" /> | |||||
| <span class="w-2.5 h-2.5 rounded-full bg-[#0b8cff] animate-pulse-dot2" /> | |||||
| <span class="w-2.5 h-2.5 rounded-full bg-[#0b8cff] animate-pulse-dot3" /> | |||||
| </div> | |||||
| <div class="text-sm font-bold text-[#172033]">正在整理名字方案</div> | |||||
| </div> | |||||
| </div> | |||||
| <div v-if="resultState.error" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10"> | |||||
| <UIcon name="i-lucide-circle-alert" class="size-12 text-red-400 mb-3 mx-auto" /> | |||||
| <div class="text-base font-medium text-[#f04438] mb-2">生成失败</div> | |||||
| <div class="text-sm text-[#7f8896] mb-4">{{ resultState.errorMsg }}</div> | |||||
| <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton> | |||||
| </div> | |||||
| <div v-if="resultState.empty" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10"> | |||||
| <UIcon name="i-lucide-search-x" class="size-12 text-[#a0aabb] mb-3 mx-auto" /> | |||||
| <div class="text-base font-bold text-[#172033] mb-1">本次没有可用名字</div> | |||||
| <div class="text-sm text-[#7f8896] max-w-xs mx-auto">{{ result.summary || 'AI 返回的候选名未通过姓名可用性筛选,请调整条件或重新生成。' }}</div> | |||||
| <UButton color="primary" size="sm" class="mt-5" @click="handleSubmit">重新生成</UButton> | |||||
| </div> | |||||
| <template v-if="resultState.success"> | |||||
| <div v-if="result.summary" class="bg-white rounded-2xl p-5 lg:p-6 shadow-sm"> | |||||
| <div class="text-sm text-[#596579] leading-relaxed">{{ result.summary }}</div> | |||||
| </div> | |||||
| <div v-for="(item, idx) in result.names" :key="idx" class="bg-white rounded-2xl p-5 lg:p-6 shadow-sm"> | |||||
| <div class="flex items-center justify-between mb-2"> | |||||
| <span class="text-2xl lg:text-3xl font-bold text-[#0b8cff]">{{ item.name }}</span> | |||||
| <span v-if="item.score" class="text-lg font-bold text-[#ff8a00]">{{ item.score }}分</span> | |||||
| </div> | |||||
| <div v-if="item.pinyin" class="text-xs text-[#7f8896] mb-3">{{ item.pinyin }}</div> | |||||
| <div class="space-y-2.5"> | |||||
| <div v-for="field in nameFields" :key="field.key"> | |||||
| <div v-if="item[field.key]" class="flex items-start gap-2.5"> | |||||
| <span class="flex-shrink-0 inline-flex items-center justify-center h-[26px] px-2 rounded-full bg-[#eef6ff] text-[#0b8cff] text-[11px] font-medium">{{ field.label }}</span> | |||||
| <span class="text-sm text-[#172033] leading-relaxed pt-0.5">{{ item[field.key] }}</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <div class="flex gap-3"> | |||||
| <UButton color="primary" variant="outline" size="lg" class="flex-1 !rounded-full" :loading="submitting" @click="handleSubmit">重新生成</UButton> | |||||
| <UButton color="primary" size="lg" class="flex-1 !rounded-full" @click="copyResult">复制结果</UButton> | |||||
| </div> | |||||
| </template> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== Mobile Bottom Bar (fixed) ========== --> | |||||
| <div class="lg:hidden fixed left-0 right-0 bottom-0 z-50 bg-white/95 backdrop-blur shadow-[0_-8px_24px_rgba(35,55,90,0.08)]" | |||||
| style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))"> | |||||
| <div class="flex items-center justify-between mb-2.5 gap-3 text-sm"> | |||||
| <div class="min-w-0 truncate text-[#7b8797]">{{ balanceText }}</div> | |||||
| <button v-if="isBalanceInsufficient" class="text-[#0b8cff] text-xs font-extrabold flex-shrink-0" @click="goRecharge">去充值</button> | |||||
| </div> | |||||
| <div class="flex gap-3"> | |||||
| <button class="w-[120px] h-12 rounded-full bg-[#eef6ff] text-[#0b8cff] text-sm font-bold" @click="goHistory">历史记录</button> | |||||
| <button class="flex-1 h-12 rounded-full text-white font-bold shadow-[0_12px_24px_rgba(11,140,255,0.2)] transition-opacity" | |||||
| :class="(canSubmit && !submitting) ? 'bg-[#0b8cff]' : 'bg-[#0b8cff] opacity-60'" | |||||
| :disabled="!canSubmit || submitting" @click="handleSubmit"> | |||||
| {{ submitting ? '生成中...' : submitText }} | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== Date Picker Modal (custom calendar grid) ========== --> | |||||
| <Teleport to="body"> | |||||
| <Transition name="modal"> | |||||
| <div v-if="dateOpen" class="fixed inset-0 z-[100] flex items-center justify-center p-4"> | |||||
| <div class="absolute inset-0 bg-black/50" @click="dateOpen = false" /> | |||||
| <div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"> | |||||
| <div class="flex items-center justify-between px-5 py-4 border-b border-gray-100"> | |||||
| <h3 class="text-base font-extrabold text-[#172033]">选择出生日期</h3> | |||||
| <button class="text-[#a0aabb] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100" @click="dateOpen = false">×</button> | |||||
| </div> | |||||
| <div class="p-5"> | |||||
| <div class="flex items-center justify-between mb-4"> | |||||
| <button type="button" class="w-9 h-9 rounded-full hover:bg-gray-100 flex items-center justify-center text-[#172033]" @click="prevMonth"> | |||||
| <UIcon name="i-lucide-chevron-left" class="size-5" /> | |||||
| </button> | |||||
| <div class="text-base font-extrabold text-[#172033]">{{ calendarYear }}年{{ calendarMonth }}月</div> | |||||
| <button type="button" class="w-9 h-9 rounded-full hover:bg-gray-100 flex items-center justify-center text-[#172033] disabled:opacity-30 disabled:cursor-not-allowed" :disabled="isNextMonthDisabled" @click="nextMonth"> | |||||
| <UIcon name="i-lucide-chevron-right" class="size-5" /> | |||||
| </button> | |||||
| </div> | |||||
| <div class="grid grid-cols-7 gap-1 text-center text-xs text-[#7f8896] mb-2"> | |||||
| <div v-for="dow in ['日', '一', '二', '三', '四', '五', '六']" :key="dow" class="h-8 flex items-center justify-center">{{ dow }}</div> | |||||
| </div> | |||||
| <div class="grid grid-cols-7 gap-1"> | |||||
| <button v-for="day in calendarDays" :key="day.key" type="button" | |||||
| class="aspect-square flex items-center justify-center text-sm rounded-full transition-colors relative" | |||||
| :class="{ | |||||
| 'text-[#cbd5e1]': day.otherMonth && !day.isSelected, | |||||
| 'text-[#172033] hover:bg-gray-100': !day.otherMonth && !day.isSelected && !day.isToday && !day.disabled, | |||||
| 'ring-1 ring-[#0b8cff] text-[#0b8cff] font-bold': day.isToday && !day.isSelected, | |||||
| 'bg-[#0b8cff] text-white font-bold': day.isSelected, | |||||
| 'opacity-30 cursor-not-allowed': day.disabled, | |||||
| }" | |||||
| :disabled="day.disabled" | |||||
| @click="selectDay(day)"> | |||||
| {{ day.day }} | |||||
| </button> | |||||
| </div> | |||||
| <div class="mt-4 text-center text-xs text-[#7f8896]">小贴士:可选过去日期,今日及之前可选</div> | |||||
| </div> | |||||
| <div class="flex gap-3 px-5 py-4 border-t border-gray-100"> | |||||
| <button type="button" class="flex-1 h-11 rounded-full border border-gray-200 text-[#596579] font-bold hover:bg-gray-50" @click="dateOpen = false">取消</button> | |||||
| <button type="button" class="flex-1 h-11 rounded-full bg-[#0b8cff] text-white font-bold hover:bg-[#0a7ce0]" @click="confirmDate">确定</button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </Transition> | |||||
| </Teleport> | |||||
| <!-- ========== Time Picker Modal (UInputTime) ========== --> | |||||
| <Teleport to="body"> | |||||
| <Transition name="modal"> | |||||
| <div v-if="timeOpen" class="fixed inset-0 z-[100] flex items-center justify-center p-4"> | |||||
| <div class="absolute inset-0 bg-black/50" @click="timeOpen = false" /> | |||||
| <div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"> | |||||
| <div class="flex items-center justify-between px-5 py-4 border-b border-gray-100"> | |||||
| <h3 class="text-base font-extrabold text-[#172033]">选择出生时分</h3> | |||||
| <button class="text-[#a0aabb] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100" @click="timeOpen = false">×</button> | |||||
| </div> | |||||
| <div class="p-6"> | |||||
| <UInputTime v-model="tempTimeValue" class="w-full" /> | |||||
| <div class="mt-4 text-center text-xs text-[#7f8896]">不确定时辰?点下方"不知道具体时间"</div> | |||||
| </div> | |||||
| <div class="flex gap-3 px-5 py-4 border-t border-gray-100"> | |||||
| <button type="button" class="flex-1 h-11 rounded-full border border-gray-200 text-[#0b8cff] font-bold hover:bg-gray-50" @click="timeUnknownPick">不知道具体时间</button> | |||||
| <button type="button" class="flex-1 h-11 rounded-full bg-[#0b8cff] text-white font-bold hover:bg-[#0a7ce0]" @click="confirmTime">确定</button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </Transition> | |||||
| </Teleport> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| import { ref, reactive, computed, watch, onMounted } from 'vue' | |||||
| import { Time } from '@internationalized/date' | |||||
| const _api = useApi() | |||||
| const api = _api // _api 是 { get, post, request },别名保持 api.post(...) 调用 | |||||
| const user = useUser() | |||||
| const router = useRouter() | |||||
| const toast = useToast() | |||||
| function showToast(message, color = 'red') { | |||||
| toast.add({ title: message, color }) | |||||
| } | |||||
| // ==================== Options (对齐 V2) ==================== | |||||
| const genderOptions = [ | |||||
| { label: '男孩', value: 1 }, | |||||
| { label: '女孩', value: 2 }, | |||||
| ] | |||||
| const sourceOptions = [ | |||||
| { id: '0', label: '不限', desc: '综合推荐' }, | |||||
| { id: '1', label: '诗经', desc: '温润雅致' }, | |||||
| { id: '2', label: '楚辞', desc: '浪漫开阔' }, | |||||
| { id: '3', label: '离骚', desc: '清朗有骨' }, | |||||
| { id: '4', label: '周易', desc: '稳重含蓄' }, | |||||
| { id: '5', label: '史记', desc: '大气有典' }, | |||||
| { id: '6', label: '诗词歌赋', desc: '文采舒展' }, | |||||
| { id: '7', label: '成语典故', desc: '寓意明确' }, | |||||
| { id: '8', label: '佛教', desc: '清净平和' }, | |||||
| { id: '9', label: '道教', desc: '自然洒脱' }, | |||||
| { id: '10', label: '自然景观', desc: '明亮灵动' }, | |||||
| ] | |||||
| const wordOptions = [ | |||||
| { id: '0', label: '不限' }, { id: '2', label: '2字' }, { id: '3', label: '3字' }, | |||||
| { id: '4', label: '4字' }, { id: '5', label: '5字' }, | |||||
| ] | |||||
| const redupOptions = [ | |||||
| { id: '0', label: '不限' }, { id: '1', label: '是' }, { id: '2', label: '否' }, | |||||
| ] | |||||
| const nameFields = [ | |||||
| { key: 'source', label: '出处' }, { key: 'wuxing', label: '五行' }, | |||||
| { key: 'meaning', label: '寓意' }, { key: 'tone', label: '音律' }, { key: 'reason', label: '推荐' }, | |||||
| ] | |||||
| // ==================== Form ==================== | |||||
| const form = reactive({ | |||||
| surname: '', sex: 0, name_source: '0', words_num: '0', | |||||
| is_redup: '0', special_word: '', content: '', | |||||
| birth_date: '', birth_clock: '', birth_time_unknown: 1, | |||||
| }) | |||||
| const sourceExpanded = ref(false) | |||||
| const visibleSourceOptions = computed(() => { | |||||
| if (sourceExpanded.value) return sourceOptions | |||||
| const selected = sourceOptions.find(i => i.id === form.name_source) | |||||
| const rows = [sourceOptions[0]] | |||||
| if (selected && selected.id !== '0') rows.push(selected) | |||||
| sourceOptions.slice(1).forEach(i => { if (rows.length < 5 && !rows.some(r => r.id === i.id)) rows.push(i) }) | |||||
| return rows | |||||
| }) | |||||
| // ==================== Birth Info ==================== | |||||
| const birthInfoIgnored = ref(false) | |||||
| const birthDate = ref('') | |||||
| const birthClock = ref('') | |||||
| const birthTimeUnknown = ref(true) | |||||
| const dateOpen = ref(false) | |||||
| const timeOpen = ref(false) | |||||
| const tempTimeValue = ref(new Time(8, 0)) | |||||
| // Custom calendar grid | |||||
| const todayDate = new Date() | |||||
| const calendarYear = ref(todayDate.getFullYear()) | |||||
| const calendarMonth = ref(todayDate.getMonth() + 1) | |||||
| const pendingDay = ref(null) // 用户在网格里点击但未确认的日期 | |||||
| const calendarDays = computed(() => { | |||||
| const year = calendarYear.value | |||||
| const month = calendarMonth.value | |||||
| const firstDayOfWeek = new Date(year, month - 1, 1).getDay() // 0=Sun | |||||
| const daysInMonth = new Date(year, month, 0).getDate() | |||||
| const daysInPrevMonth = new Date(year, month - 1, 0).getDate() | |||||
| const todayStr = formatYMD(todayDate) | |||||
| const currentPending = pendingDay.value | |||||
| const days = [] | |||||
| // 上月填充 | |||||
| for (let i = firstDayOfWeek - 1; i >= 0; i--) { | |||||
| const d = daysInPrevMonth - i | |||||
| const dateStr = formatYMD(year, month - 1, d) | |||||
| days.push({ key: `prev-${dateStr}`, day: d, otherMonth: true, date: dateStr, disabled: true, isToday: false, isSelected: false }) | |||||
| } | |||||
| // 当月 | |||||
| for (let d = 1; d <= daysInMonth; d++) { | |||||
| const dateStr = formatYMD(year, month, d) | |||||
| const isFuture = dateStr > todayStr | |||||
| const isSelected = !!currentPending && currentPending === dateStr | |||||
| days.push({ | |||||
| key: `cur-${dateStr}`, day: d, otherMonth: false, date: dateStr, | |||||
| disabled: isFuture, isToday: dateStr === todayStr, isSelected, | |||||
| }) | |||||
| } | |||||
| // 下月填充至 42 格 | |||||
| let nextDay = 1 | |||||
| while (days.length < 42) { | |||||
| const dateStr = formatYMD(year, month + 1, nextDay) | |||||
| days.push({ key: `next-${dateStr}`, day: nextDay, otherMonth: true, date: dateStr, disabled: true, isToday: false, isSelected: false }) | |||||
| nextDay++ | |||||
| } | |||||
| return days | |||||
| }) | |||||
| const isNextMonthDisabled = computed(() => { | |||||
| const today = new Date() | |||||
| return calendarYear.value > today.getFullYear() || | |||||
| (calendarYear.value === today.getFullYear() && calendarMonth.value >= today.getMonth() + 1) | |||||
| }) | |||||
| function formatYMD(year, month, day) { | |||||
| const m = month < 0 ? 12 : month > 12 ? 1 : month | |||||
| return `${year}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}` | |||||
| } | |||||
| function prevMonth() { | |||||
| if (calendarMonth.value === 1) { calendarMonth.value = 12; calendarYear.value-- } | |||||
| else calendarMonth.value-- | |||||
| } | |||||
| function nextMonth() { | |||||
| if (isNextMonthDisabled.value) return | |||||
| if (calendarMonth.value === 12) { calendarMonth.value = 1; calendarYear.value++ } | |||||
| else calendarMonth.value++ | |||||
| } | |||||
| function selectDay(day) { | |||||
| if (day.disabled) return | |||||
| pendingDay.value = day.date | |||||
| } | |||||
| function toggleBirthInfo() { | |||||
| birthInfoIgnored.value = !birthInfoIgnored.value | |||||
| syncBirthFields() | |||||
| } | |||||
| function toggleBirthTimeUnknown() { | |||||
| if (birthInfoIgnored.value) return | |||||
| birthTimeUnknown.value = !birthTimeUnknown.value | |||||
| if (birthTimeUnknown.value) birthClock.value = '' | |||||
| syncBirthFields() | |||||
| } | |||||
| function openDatePicker() { | |||||
| if (birthInfoIgnored.value) return | |||||
| if (birthDate.value) { | |||||
| const p = birthDate.value.split('-') | |||||
| calendarYear.value = +p[0] | |||||
| calendarMonth.value = +p[1] | |||||
| pendingDay.value = birthDate.value | |||||
| } else { | |||||
| calendarYear.value = todayDate.getFullYear() | |||||
| calendarMonth.value = todayDate.getMonth() + 1 | |||||
| pendingDay.value = null | |||||
| } | |||||
| dateOpen.value = true | |||||
| } | |||||
| function confirmDate() { | |||||
| if (pendingDay.value) { | |||||
| birthDate.value = pendingDay.value | |||||
| syncBirthFields() | |||||
| } | |||||
| dateOpen.value = false | |||||
| } | |||||
| function openTimePicker() { | |||||
| if (birthInfoIgnored.value) return | |||||
| const clock = birthClock.value || '08:00' | |||||
| const [h, m] = clock.split(':') | |||||
| tempTimeValue.value = new Time(+h, +m) | |||||
| timeOpen.value = true | |||||
| } | |||||
| function confirmTime() { | |||||
| const t = tempTimeValue.value | |||||
| if (t) { | |||||
| birthClock.value = `${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}` | |||||
| birthTimeUnknown.value = false | |||||
| syncBirthFields() | |||||
| } | |||||
| timeOpen.value = false | |||||
| } | |||||
| function timeUnknownPick() { | |||||
| birthTimeUnknown.value = true | |||||
| birthClock.value = '' | |||||
| syncBirthFields() | |||||
| timeOpen.value = false | |||||
| } | |||||
| function syncBirthFields() { | |||||
| if (birthInfoIgnored.value) { | |||||
| form.birth_date = ''; form.birth_clock = ''; form.birth_time_unknown = 1 | |||||
| return | |||||
| } | |||||
| form.birth_date = birthDate.value || '' | |||||
| form.birth_clock = birthTimeUnknown.value ? '' : (birthClock.value || '') | |||||
| form.birth_time_unknown = birthTimeUnknown.value ? 1 : 0 | |||||
| } | |||||
| const birthClockText = computed(() => { | |||||
| if (birthTimeUnknown.value) return '不知道具体时间' | |||||
| return birthClock.value || '请选择时分' | |||||
| }) | |||||
| const previewName = '白敬亭' | |||||
| // 农历 | |||||
| const birthLunarText = ref('') | |||||
| watch(birthDate, async (val) => { | |||||
| if (!val) { birthLunarText.value = ''; return } | |||||
| try { | |||||
| const { Solar } = await import('lunar-javascript') | |||||
| const p = val.split('-') | |||||
| const lunar = Solar.fromYmd(+p[0], +p[1], +p[2]).getLunar() | |||||
| birthLunarText.value = `农历${lunar.getYearInGanZhi()}年 ${lunar.getMonthInChinese()}${lunar.getDayInChinese()}` | |||||
| } catch { birthLunarText.value = '' } | |||||
| }, { immediate: true }) | |||||
| // ==================== Billing & Submit ==================== | |||||
| const billing = reactive({ loaded: false, points: 0 }) | |||||
| const balance = reactive({ points: 0 }) | |||||
| async function loadBilling() { | |||||
| try { | |||||
| const res = await api.post('/billing/config', { app_key: 'ai-name' }) | |||||
| if (res.code === 0 && res.data) { | |||||
| billing.points = res.data.quote?.points || res.data.points || 0 | |||||
| if (res.data.balance) balance.points = res.data.balance.points || res.data.balance.point_balance || 0 | |||||
| } | |||||
| } catch {} finally { billing.loaded = true } | |||||
| } | |||||
| async function loadBalance() { | |||||
| try { | |||||
| const res = await api.post('/user/getbalance', {}) | |||||
| if (res.code === 0 && res.data) balance.points = res.data.points || res.data.point_balance || 0 | |||||
| } catch {} | |||||
| } | |||||
| onMounted(() => { loadBilling(); if (user.isLogin.value) loadBalance() }) | |||||
| const submitting = ref(false) | |||||
| const questionTitle = ref('') | |||||
| const canSubmit = computed(() => !!form.surname && !submitting.value) | |||||
| const balanceText = computed(() => { | |||||
| const cost = Number(billing.points || 0) | |||||
| if (!user.isLogin.value) return cost ? `AI取名 · ${cost}点/次` : 'AI取名' | |||||
| return cost ? `余额${balance.points}点 · 预计消耗${cost}点` : `余额${balance.points}点` | |||||
| }) | |||||
| const submitText = computed(() => { | |||||
| const cost = Number(billing.points || 0) | |||||
| if (!user.isLogin.value) return cost ? `登录后取名 · ${cost}点` : '登录后取名' | |||||
| return cost ? `立即取名 · ${cost}点` : '立即取名' | |||||
| }) | |||||
| const isBalanceInsufficient = computed(() => { | |||||
| const cost = Number(billing.points || 0) | |||||
| return user.isLogin.value && cost > 0 && balance.points >= 0 && balance.points < cost | |||||
| }) | |||||
| const resultState = ref({ visible: false, loading: false, error: false, empty: false, success: false, errorMsg: '' }) | |||||
| const result = ref({ summary: '', names: [] }) | |||||
| async function handleSubmit() { | |||||
| if (!user.isLogin.value) { showToast('请先登录'); return router.push('/login') } | |||||
| if (!form.surname) { showToast('请输入姓氏'); return } | |||||
| if (submitting.value) return | |||||
| syncBirthFields() | |||||
| submitting.value = true | |||||
| resultState.value = { visible: true, loading: true, error: false, empty: false, success: false, errorMsg: '' } | |||||
| result.value = { summary: '', names: [] } | |||||
| questionTitle.value = `${form.surname}${form.sex === 2 ? '女孩' : form.sex === 1 ? '男孩' : ''}好名字` | |||||
| const params = { | |||||
| surname: form.surname, sex: form.sex, name_source: form.name_source, | |||||
| words_num: form.words_num, is_redup: form.is_redup, special_word: form.special_word, | |||||
| content: form.content, model: 4, | |||||
| birth_date: form.birth_date, birth_clock: form.birth_clock, birth_time_unknown: form.birth_time_unknown, | |||||
| } | |||||
| try { | |||||
| const res = await api.post('/ainame/send', params) | |||||
| if (res.code === 1011) { | |||||
| resultState.value = { ...resultState.value, loading: false, error: true, errorMsg: '点数不足,请充值后重试' } | |||||
| return | |||||
| } | |||||
| if (res.code !== 0) { | |||||
| resultState.value = { ...resultState.value, loading: false, error: true, errorMsg: res.msg || '生成失败' } | |||||
| return | |||||
| } | |||||
| const data = res.data || {} | |||||
| const parsed = parseResult(data.content || data) | |||||
| if (parsed.names && parsed.names.length > 0) { | |||||
| result.value = parsed | |||||
| resultState.value = { visible: true, loading: false, error: false, empty: false, success: true, errorMsg: '' } | |||||
| } else { | |||||
| result.value = parsed | |||||
| resultState.value = { visible: true, loading: false, error: false, empty: true, success: false, errorMsg: '' } | |||||
| } | |||||
| } catch (e) { | |||||
| resultState.value = { ...resultState.value, loading: false, error: true, errorMsg: e.msg || '网络异常,请重试' } | |||||
| } finally { submitting.value = false } | |||||
| } | |||||
| function parseResult(content) { | |||||
| if (!content) return { summary: '', names: [] } | |||||
| let text = String(content).trim().replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim() | |||||
| const line = parseJsonLines(text); if (line.names.length) return line | |||||
| const marked = parseMarkedText(text); if (marked.names.length) return marked | |||||
| const s = text.indexOf('{'), e = text.lastIndexOf('}') | |||||
| if (s >= 0 && e > s) text = text.substring(s, e + 1) | |||||
| try { const d = JSON.parse(text); return { summary: d.summary || '', names: Array.isArray(d.names) ? d.names : [] } } | |||||
| catch { return { summary: text, names: [] } } | |||||
| } | |||||
| function parseJsonLines(text) { | |||||
| const d = { summary: '', names: [] } | |||||
| String(text || '').split(/\r?\n/).forEach(l => { l = l.trim(); if (!l) return; try { const i = JSON.parse(l); if (i.type === 'summary') d.summary = i.content || ''; if (i.type === 'name' && i.data) d.names.push(i.data) } catch {} }) | |||||
| return d | |||||
| } | |||||
| function parseMarkedText(text) { | |||||
| const d = { summary: '', names: [] }; const v = String(text || '') | |||||
| const sm = v.match(/整体建议[::]([\s\S]*?)(?=\n\s*【|$)/); if (sm) d.summary = sm[1].trim() | |||||
| const reg = /【([^】]+)】([\s\S]*?)(?=\n\s*【|$)/g; let m | |||||
| while ((m = reg.exec(v))) { | |||||
| const body = m[2] || '', name = resolveNameBlockTitle(m[1], body) | |||||
| d.names.push({ name, pinyin: mf(body, '拼音'), score: parseInt(mf(body, '评分')) || 90, source: mf(body, '出处'), wuxing: mf(body, '五行'), meaning: mf(body, '寓意'), tone: mf(body, '音律'), reason: mf(body, '推荐') }) | |||||
| } | |||||
| d.names = d.names.filter(i => i.name).slice(0, 5); return d | |||||
| } | |||||
| function resolveNameBlockTitle(title, body) { | |||||
| const n = String(title || '').trim(); if (!['姓名', '名字', '候选名'].includes(n)) return n | |||||
| const fr = /^(拼音|评分|出处|五行|寓意|音律|推荐)[::]/; const line = String(body || '').split(/\r?\n/).map(i => i.trim()).find(i => i && !fr.test(i)) | |||||
| return line ? line.replace(/^姓名[::]/, '').trim() : n | |||||
| } | |||||
| function mf(body, label) { const r = new RegExp(`${label}[::]([^\\n\\r]*)`); const m = String(body || '').match(r); return m ? m[1].trim() : '' } | |||||
| function copyResult() { | |||||
| const names = result.value.names || [] | |||||
| if (!names.length) { showToast('暂无可复制内容'); return } | |||||
| const lines = [] | |||||
| if (result.value.summary) lines.push(result.value.summary) | |||||
| names.forEach(item => { | |||||
| lines.push(`${item.name}${item.score ? `(${item.score}分)` : ''}`) | |||||
| if (item.pinyin) lines.push(`拼音:${item.pinyin}`) | |||||
| if (item.source) lines.push(`出处:${item.source}`) | |||||
| if (item.wuxing) lines.push(`五行:${item.wuxing}`) | |||||
| if (item.meaning) lines.push(`寓意:${item.meaning}`) | |||||
| if (item.tone) lines.push(`音律:${item.tone}`) | |||||
| if (item.reason) lines.push(`推荐:${item.reason}`) | |||||
| lines.push('') | |||||
| }) | |||||
| navigator.clipboard.writeText(lines.join('\n')).then(() => showToast('复制成功', 'primary')).catch(() => showToast('复制失败')) | |||||
| } | |||||
| function goRecharge() { router.push('/recharge') } | |||||
| function goHistory() { router.push('/ai-name-history') } | |||||
| </script> | |||||
| <style scoped> | |||||
| .animate-pulse-dot { animation: dotPulse 1.1s ease-in-out infinite; } | |||||
| .animate-pulse-dot2 { animation: dotPulse 1.1s ease-in-out 0.15s infinite; } | |||||
| .animate-pulse-dot3 { animation: dotPulse 1.1s ease-in-out 0.3s infinite; } | |||||
| @keyframes dotPulse { | |||||
| 0%, 100% { opacity: 0.35; transform: scale(0.82); } | |||||
| 50% { opacity: 1; transform: scale(1); } | |||||
| } | |||||
| .modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; } | |||||
| .modal-enter-from, .modal-leave-to { opacity: 0; } | |||||
| .modal-enter-active > div:last-child, .modal-leave-active > div:last-child { transition: transform 0.2s ease; } | |||||
| .modal-enter-from > div:last-child, .modal-leave-to > div:last-child { transform: scale(0.95); } | |||||
| </style> | |||||
| @@ -0,0 +1,14 @@ | |||||
| <template> | |||||
| <div /> | |||||
| </template> | |||||
| <script setup> | |||||
| import { articleUrl, useArticleDetail } from '~/composables/useArticle' | |||||
| // 兼容旧 sitemap 中的百科详情地址,并收敛到当前资讯规范路径。 | |||||
| const nuxtApp = useNuxtApp() | |||||
| const { detail } = await useArticleDetail() | |||||
| await nuxtApp.runWithContext(() => | |||||
| navigateTo(articleUrl(detail.value), { redirectCode: 301, replace: true })) | |||||
| </script> | |||||
| @@ -1,174 +1,601 @@ | |||||
| <template> | <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 class="bill-page"> | |||||
| <AppHero | |||||
| kicker="POINTS LEDGER" | |||||
| title="点数账单" | |||||
| desc="每一次点数变化都清晰可查,充值、消费与奖励记录一目了然。" | |||||
| > | |||||
| <template #aside> | |||||
| <div | |||||
| class="relative w-full overflow-hidden rounded-[28px] bg-gradient-to-br from-[var(--c-0758c9)] via-[var(--c-0b8cff)] to-[var(--c-55c8ff)] p-5 text-white shadow-[0_22px_48px_var(--sh-11-112-220-240)] lg:w-[360px] lg:p-6" | |||||
| aria-label="点数余额" | |||||
| > | |||||
| <div class="pointer-events-none absolute -right-10 -top-14 size-40 rounded-full border border-white/15 bg-white/10" /> | |||||
| <div class="pointer-events-none absolute -bottom-20 -left-10 size-44 rounded-full bg-[var(--c-004da8)]/20 blur-xl" /> | |||||
| <div class="relative"> | |||||
| <div class="flex items-center justify-between gap-4"> | |||||
| <div class="flex items-center gap-2.5"> | |||||
| <span class="flex size-9 items-center justify-center rounded-xl bg-white/15"> | |||||
| <UIcon name="i-lucide-wallet-cards" class="size-4.5" /> | |||||
| </span> | |||||
| <div> | |||||
| <div class="text-[11px] font-bold tracking-[0.12em] text-white/65">AVAILABLE POINTS</div> | |||||
| <div class="mt-0.5 text-xs font-bold text-white/90">当前可用点数</div> | |||||
| </div> | |||||
| </div> | |||||
| <span class="flex items-center gap-1.5 rounded-full bg-white/12 px-2.5 py-1 text-[11px] font-bold text-white/80"> | |||||
| <span class="size-1.5 rounded-full bg-[var(--c-8affd8)]" /> | |||||
| 多端同步 | |||||
| </span> | |||||
| </div> | |||||
| <div class="mt-6 flex items-end justify-between gap-5"> | |||||
| <div class="flex min-w-0 items-end"> | |||||
| <span class="truncate text-[40px] font-extrabold leading-none tracking-tight tabular-nums lg:text-[44px]"> | |||||
| {{ balanceText }} | |||||
| </span> | |||||
| <span v-if="balanceUnit" class="mb-1 ml-2 text-sm font-bold text-white/70">{{ balanceUnit }}</span> | |||||
| </div> | |||||
| <NuxtLink | |||||
| v-if="isLogin" | |||||
| to="/recharge" | |||||
| class="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-full bg-[var(--c-ffffff)] px-4 text-sm font-extrabold text-[var(--c-086fd5)] shadow-[0_8px_18px_var(--sh-0-65-140-160)] transition-colors hover:bg-[var(--c-f5fbff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-e8eef7)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--c-0b8cff)] motion-reduce:transition-none" | |||||
| > | |||||
| 去充值 | |||||
| <UIcon name="i-lucide-arrow-up-right" class="size-4" /> | |||||
| </NuxtLink> | |||||
| <button | |||||
| v-else | |||||
| type="button" | |||||
| class="inline-flex h-10 shrink-0 cursor-pointer items-center rounded-full bg-[var(--c-ffffff)] px-4 text-sm font-extrabold text-[var(--c-086fd5)] shadow-[0_8px_18px_var(--sh-0-65-140-160)] transition-colors hover:bg-[var(--c-f5fbff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-e8eef7)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--c-0b8cff)] motion-reduce:transition-none" | |||||
| @click="showLogin = true" | |||||
| > | |||||
| 登录查看 | |||||
| </button> | |||||
| </div> | |||||
| <div class="mt-6 flex items-center gap-2 border-t border-white/15 pt-4 text-[11px] font-medium text-white/70"> | |||||
| <UIcon name="i-lucide-shield-check" class="size-3.5" /> | |||||
| 账目实时同步,点数长期有效 | |||||
| </div> | |||||
| </div> | |||||
| </div> | </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 }} | |||||
| </template> | |||||
| </AppHero> | |||||
| <main class="mx-auto max-w-7xl px-4 py-7 sm:px-6 lg:px-8 lg:py-10"> | |||||
| <section | |||||
| v-if="!isLogin" | |||||
| class="relative overflow-hidden rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] px-6 py-14 text-center shadow-[0_16px_40px_var(--sh-33-68-112-60)] sm:py-18" | |||||
| aria-labelledby="bill-login-title" | |||||
| > | |||||
| <div class="pointer-events-none absolute left-1/2 top-0 h-36 w-72 -translate-x-1/2 rounded-full bg-[var(--c-eaf6ff)] blur-3xl" /> | |||||
| <div class="relative"> | |||||
| <div class="mx-auto flex size-16 items-center justify-center rounded-2xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-8 ring-[var(--c-f7fbff)]"> | |||||
| <UIcon name="i-lucide-receipt-text" class="size-7" /> | |||||
| </div> | |||||
| <h2 id="bill-login-title" class="mt-6 text-xl font-extrabold text-[var(--c-172033)]">登录后查看你的点数账单</h2> | |||||
| <p class="mx-auto mt-2 max-w-md text-sm leading-6 text-[var(--c-66758a)]"> | |||||
| 登录后即可同步各端余额,随时核对每一笔充值、创作消费与奖励记录。 | |||||
| </p> | </p> | ||||
| <p class="text-xs text-slate-400">余额 {{ bill.balance }}</p> | |||||
| <div class="mt-5 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-xs font-bold text-[var(--c-7b8797)]"> | |||||
| <span class="inline-flex items-center gap-1.5"> | |||||
| <UIcon name="i-lucide-check" class="size-3.5 text-[var(--c-17a65a)]" /> | |||||
| 多端记录同步 | |||||
| </span> | |||||
| <span class="inline-flex items-center gap-1.5"> | |||||
| <UIcon name="i-lucide-check" class="size-3.5 text-[var(--c-17a65a)]" /> | |||||
| 点数去向清晰 | |||||
| </span> | |||||
| <span class="inline-flex items-center gap-1.5"> | |||||
| <UIcon name="i-lucide-check" class="size-3.5 text-[var(--c-17a65a)]" /> | |||||
| 变动余额可追溯 | |||||
| </span> | |||||
| </div> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-7 inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-full bg-[var(--c-0b8cff)] px-7 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-240)] transition-colors hover:bg-[var(--c-087dde)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 motion-reduce:transition-none" | |||||
| @click="showLogin = true" | |||||
| > | |||||
| <UIcon name="i-lucide-log-in" class="size-4" /> | |||||
| 立即登录 | |||||
| </button> | |||||
| </div> | </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> | |||||
| </section> | |||||
| <template v-else> | |||||
| <section class="grid gap-3 sm:grid-cols-3" aria-label="点数概览"> | |||||
| <article | |||||
| v-for="item in summaryStats" | |||||
| :key="item.key" | |||||
| class="relative overflow-hidden rounded-2xl border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] p-4 shadow-[0_8px_24px_var(--sh-34-68-112-45)] sm:p-5" | |||||
| > | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <div> | |||||
| <p class="text-xs font-bold text-[var(--c-7b8797)]">{{ item.label }}</p> | |||||
| <p class="mt-2 text-2xl font-extrabold tracking-tight tabular-nums" :class="item.valueTone"> | |||||
| {{ hasLoaded ? item.value : '—' }} | |||||
| </p> | |||||
| <p class="mt-1 text-[11px] text-[var(--c-9aa5b5)]">{{ item.helper }}</p> | |||||
| </div> | |||||
| <span class="flex size-10 shrink-0 items-center justify-center rounded-xl" :class="item.iconTone"> | |||||
| <UIcon :name="item.icon" class="size-4.5" /> | |||||
| </span> | |||||
| </div> | |||||
| </article> | |||||
| </section> | |||||
| <section | |||||
| class="mt-6 overflow-hidden rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] shadow-[0_16px_40px_var(--sh-34-68-112-60)]" | |||||
| aria-labelledby="bill-list-title" | |||||
| :aria-busy="loading" | |||||
| > | |||||
| <header class="border-b border-[var(--c-edf2f7)] px-4 pb-4 pt-5 sm:px-6 sm:pb-5 sm:pt-6"> | |||||
| <div class="flex flex-wrap items-end justify-between gap-3"> | |||||
| <div> | |||||
| <div class="flex items-center gap-2"> | |||||
| <h2 id="bill-list-title" class="text-lg font-extrabold text-[var(--c-172033)] sm:text-xl">流水明细</h2> | |||||
| <span class="rounded-full bg-[var(--c-f1f5fa)] px-2.5 py-1 text-[11px] font-bold text-[var(--c-7b8797)]"> | |||||
| {{ total }} 条 | |||||
| </span> | |||||
| </div> | |||||
| <p class="mt-1.5 text-xs text-[var(--c-8a97a8)]">按时间倒序展示,最近的点数变动排在最前</p> | |||||
| </div> | |||||
| <div v-if="list.length" class="text-xs font-medium text-[var(--c-9aa5b5)]"> | |||||
| 已显示 {{ list.length }} 条 | |||||
| </div> | |||||
| </div> | |||||
| <div class="bill-tabs -mx-1 mt-5 overflow-x-auto px-1 pb-1" role="tablist" aria-label="账单类型"> | |||||
| <div class="grid auto-cols-[minmax(104px,1fr)] grid-flow-col gap-2 sm:grid-flow-row sm:grid-cols-5"> | |||||
| <button | |||||
| v-for="item in tabs" | |||||
| :key="item.key" | |||||
| type="button" | |||||
| role="tab" | |||||
| class="flex h-11 cursor-pointer items-center justify-center gap-2 rounded-xl px-3 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 disabled:cursor-wait motion-reduce:transition-none" | |||||
| :class="activeTab === item.key | |||||
| ? 'bg-[var(--c-0b8cff)] text-white shadow-[0_8px_18px_var(--sh-11-140-255-200)]' | |||||
| : 'bg-[var(--c-f6f9fd)] text-[var(--c-66758a)] hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]'" | |||||
| :aria-selected="activeTab === item.key" | |||||
| :disabled="loading" | |||||
| @click="changeTab(item.key)" | |||||
| > | |||||
| <UIcon :name="item.icon" class="size-4 shrink-0" /> | |||||
| <span>{{ item.name }}</span> | |||||
| <span | |||||
| class="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-extrabold tabular-nums" | |||||
| :class="activeTab === item.key ? 'bg-white/18 text-white' : 'bg-[var(--c-ffffff)] text-[var(--c-8a97a8)]'" | |||||
| > | |||||
| {{ tabCounts[item.key] || 0 }} | |||||
| </span> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| </header> | |||||
| <div | |||||
| v-if="initialLoading" | |||||
| class="divide-y divide-[var(--c-edf2f7)]" | |||||
| role="status" | |||||
| aria-label="正在加载账单" | |||||
| > | |||||
| <div v-for="i in 5" :key="i" class="flex items-center gap-4 px-4 py-5 sm:px-6"> | |||||
| <div class="size-11 shrink-0 animate-pulse rounded-2xl bg-[var(--c-edf2f7)] motion-reduce:animate-none" /> | |||||
| <div class="min-w-0 flex-1"> | |||||
| <div class="h-3.5 w-36 animate-pulse rounded-full bg-[var(--c-edf2f7)] motion-reduce:animate-none" /> | |||||
| <div class="mt-3 h-3 w-56 max-w-full animate-pulse rounded-full bg-[var(--c-f1f5fa)] motion-reduce:animate-none" /> | |||||
| </div> | |||||
| <div class="hidden w-20 sm:block"> | |||||
| <div class="ml-auto h-4 w-14 animate-pulse rounded-full bg-[var(--c-edf2f7)] motion-reduce:animate-none" /> | |||||
| <div class="ml-auto mt-3 h-3 w-20 animate-pulse rounded-full bg-[var(--c-f1f5fa)] motion-reduce:animate-none" /> | |||||
| </div> | |||||
| <span class="sr-only">加载中</span> | |||||
| </div> | |||||
| </div> | |||||
| <div v-else-if="loadError && !list.length" class="px-5 py-16 text-center sm:py-20" role="alert"> | |||||
| <div class="mx-auto flex size-14 items-center justify-center rounded-2xl bg-[var(--c-fff3f0)] text-[var(--c-e85f1c)] ring-8 ring-[var(--c-fff9f7)]"> | |||||
| <UIcon name="i-lucide-circle-alert" class="size-6" /> | |||||
| </div> | |||||
| <h3 class="mt-6 text-base font-extrabold text-[var(--c-172033)]">账单加载失败</h3> | |||||
| <p class="mx-auto mt-2 max-w-sm text-sm leading-6 text-[var(--c-8a97a8)]">{{ loadError }}</p> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-6 inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-5 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e1f1ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 motion-reduce:transition-none" | |||||
| @click="fetchList(true)" | |||||
| > | |||||
| <UIcon name="i-lucide-refresh-cw" class="size-3.5" /> | |||||
| 重新加载 | |||||
| </button> | |||||
| </div> | |||||
| <div v-else-if="list.length"> | |||||
| <div class="hidden grid-cols-[minmax(0,1fr)_150px] border-b border-[var(--c-edf2f7)] bg-[var(--c-fbfcfe)] px-6 py-2.5 text-[11px] font-bold text-[var(--c-9aa5b5)] sm:grid"> | |||||
| <span>记录</span> | |||||
| <span class="text-right">点数变动</span> | |||||
| </div> | |||||
| <div class="divide-y divide-[var(--c-edf2f7)]"> | |||||
| <article | |||||
| v-for="item in list" | |||||
| :key="item.id" | |||||
| class="grid gap-4 px-4 py-5 transition-colors hover:bg-[var(--c-fbfdff)] sm:grid-cols-[minmax(0,1fr)_150px] sm:items-center sm:px-6 motion-reduce:transition-none" | |||||
| > | |||||
| <div class="flex min-w-0 items-start gap-3.5"> | |||||
| <span | |||||
| class="flex size-11 shrink-0 items-center justify-center rounded-2xl ring-1 ring-inset" | |||||
| :class="typeTone(item.type)" | |||||
| aria-hidden="true" | |||||
| > | |||||
| <UIcon :name="getTypeIcon(item.type)" class="size-4.5" /> | |||||
| </span> | |||||
| <div class="min-w-0 flex-1"> | |||||
| <div class="flex min-w-0 flex-wrap items-center gap-2"> | |||||
| <h3 class="min-w-0 truncate text-sm font-extrabold text-[var(--c-172033)] sm:text-[15px]"> | |||||
| {{ item.title || '点数变动' }} | |||||
| </h3> | |||||
| <span | |||||
| class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-extrabold" | |||||
| :class="typeBadgeTone(item.type)" | |||||
| > | |||||
| {{ item.type_text || '其他' }} | |||||
| </span> | |||||
| </div> | |||||
| <p v-if="item.desc" class="mt-1.5 line-clamp-2 text-xs leading-5 text-[var(--c-66758a)] sm:truncate"> | |||||
| {{ item.desc }} | |||||
| </p> | |||||
| <div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--c-9aa5b5)]"> | |||||
| <span class="inline-flex items-center gap-1"> | |||||
| <UIcon name="i-lucide-clock-3" class="size-3" /> | |||||
| {{ item.time || '时间未知' }} | |||||
| </span> | |||||
| <!-- 充值流水带上订单号,方便对账 / 客服核单,点击可复制 --> | |||||
| <button | |||||
| v-if="orderNo(item)" | |||||
| type="button" | |||||
| class="inline-flex max-w-full cursor-pointer items-center gap-1 transition-colors hover:text-[var(--c-0b8cff)]" | |||||
| :title="`点击复制订单号 ${orderNo(item)}`" | |||||
| @click="copyOrderNo(orderNo(item))" | |||||
| > | |||||
| <UIcon name="i-lucide-receipt" class="size-3 shrink-0" /> | |||||
| <span class="truncate tabular-nums">订单号 {{ orderNo(item) }}</span> | |||||
| <UIcon name="i-lucide-copy" class="size-3 shrink-0" /> | |||||
| </button> | |||||
| <span class="sm:hidden">变动后 {{ formatNumber(item.balance_after) }} 点</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <div class="flex items-center justify-between border-t border-dashed border-[var(--c-e8eef7)] pt-3 sm:block sm:border-0 sm:pt-0 sm:text-right"> | |||||
| <span class="text-[11px] font-medium text-[var(--c-9aa5b5)] sm:hidden">点数变动</span> | |||||
| <div> | |||||
| <div class="text-lg font-extrabold tabular-nums" :class="amountTone(item.amount)"> | |||||
| {{ formatAmount(item.amount) }} | |||||
| </div> | |||||
| <div class="mt-1 hidden text-[11px] text-[var(--c-9aa5b5)] sm:block"> | |||||
| 变动后 {{ formatNumber(item.balance_after) }} 点 | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </article> | |||||
| </div> | |||||
| </div> | |||||
| <div v-else class="px-5 py-16 text-center sm:py-20"> | |||||
| <div class="mx-auto flex size-14 items-center justify-center rounded-2xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-8 ring-[var(--c-f8fbff)]"> | |||||
| <UIcon :name="activeTab === 'all' ? 'i-lucide-receipt-text' : 'i-lucide-list-filter'" class="size-6" /> | |||||
| </div> | |||||
| <h3 class="mt-6 text-base font-extrabold text-[var(--c-172033)]"> | |||||
| {{ activeTab === 'all' ? '还没有点数记录' : `暂无${activeTabName}` }} | |||||
| </h3> | |||||
| <p class="mx-auto mt-2 max-w-sm text-sm leading-6 text-[var(--c-8a97a8)]"> | |||||
| {{ activeTab === 'all' ? '充值或使用 AI 工具后,点数变化会自动记录在这里。' : '当前分类暂时没有记录,可以看看其他类型的流水。' }} | |||||
| </p> | |||||
| <NuxtLink | |||||
| v-if="activeTab === 'all'" | |||||
| to="/recharge" | |||||
| class="mt-6 inline-flex h-10 items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-5 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e1f1ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 motion-reduce:transition-none" | |||||
| > | |||||
| 去充值 | |||||
| <UIcon name="i-lucide-arrow-right" class="size-3.5" /> | |||||
| </NuxtLink> | |||||
| <button | |||||
| v-else | |||||
| type="button" | |||||
| class="mt-6 inline-flex h-10 cursor-pointer items-center rounded-full bg-[var(--c-eef7ff)] px-5 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e1f1ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 motion-reduce:transition-none" | |||||
| @click="changeTab('all')" | |||||
| > | |||||
| 查看全部记录 | |||||
| </button> | |||||
| </div> | |||||
| <footer v-if="list.length" class="border-t border-[var(--c-edf2f7)] px-4 py-4 sm:px-6"> | |||||
| <div v-if="loading" class="flex items-center justify-center gap-2 py-1 text-xs font-bold text-[var(--c-8a97a8)]" role="status"> | |||||
| <span class="size-4 animate-spin rounded-full border-2 border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)] motion-reduce:animate-none" /> | |||||
| 正在加载更多 | |||||
| </div> | |||||
| <button | |||||
| v-else-if="hasMore" | |||||
| type="button" | |||||
| class="mx-auto flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-6 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e1f1ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 motion-reduce:transition-none" | |||||
| @click="loadMore" | |||||
| > | |||||
| 加载更多 | |||||
| <UIcon name="i-lucide-chevron-down" class="size-3.5" /> | |||||
| </button> | |||||
| <div v-else class="flex items-center justify-center gap-3 py-1 text-[11px] text-[var(--c-a0aabb)]"> | |||||
| <span class="h-px w-8 bg-[var(--c-e6edf5)]" /> | |||||
| 已经到底了 | |||||
| <span class="h-px w-8 bg-[var(--c-e6edf5)]" /> | |||||
| </div> | |||||
| </footer> | |||||
| </section> | |||||
| <div class="mt-4 flex items-start gap-2 rounded-2xl bg-[var(--c-eef7ff)]/70 px-4 py-3 text-xs leading-5 text-[var(--c-66758a)]"> | |||||
| <UIcon name="i-lucide-info" class="mt-0.5 size-3.5 shrink-0 text-[var(--c-0b8cff)]" /> | |||||
| <p>点数账单由系统自动生成。如对某条记录有疑问,请保留记录时间和变动后余额,以便快速核对。</p> | |||||
| </div> | |||||
| </template> | |||||
| </main> | |||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| <script setup> | <script setup> | ||||
| useSeoMeta({ | useSeoMeta({ | ||||
| title: '账单明细 - 奇想宇宙', | |||||
| description: '查看账户点数变动明细', | |||||
| title: '点数账单 - AI在线', | |||||
| description: '查看点数充值、消费、奖励与余额变动明细', | |||||
| }) | }) | ||||
| const { isLogin } = useUser() | |||||
| const { userInfo, isLogin, showLogin } = useUser() | |||||
| const { post } = useApi() | const { post } = useApi() | ||||
| const toast = useToast() | |||||
| const billTabs = [ | |||||
| { label: '全部', key: 'all' }, | |||||
| { label: '充值', key: 'recharge' }, | |||||
| { label: '消费', key: 'consume' }, | |||||
| { label: '奖励', key: 'reward' }, | |||||
| const tabs = [ | |||||
| { key: 'all', name: '全部', icon: 'i-lucide-list' }, | |||||
| { key: 'recharge', name: '充值', icon: 'i-lucide-wallet-cards' }, | |||||
| { key: 'consume', name: '消费', icon: 'i-lucide-sparkles' }, | |||||
| { key: 'reward', name: '奖励', icon: 'i-lucide-gift' }, | |||||
| { key: 'other', name: '其他', icon: 'i-lucide-ellipsis' }, | |||||
| ] | ] | ||||
| const activeType = ref('all') | |||||
| const loading = ref(true) | |||||
| const bills = ref([]) | |||||
| const activeTab = ref('all') | |||||
| const list = ref([]) | |||||
| const page = ref(1) | const page = ref(1) | ||||
| const hasMore = ref(false) | |||||
| const summary = reactive({ | |||||
| totalRecharge: 0, | |||||
| totalConsume: 0, | |||||
| totalReward: 0, | |||||
| const pageSize = 10 | |||||
| const total = ref(0) | |||||
| const loading = ref(false) | |||||
| const loadError = ref('') | |||||
| const hasLoaded = ref(false) | |||||
| const balance = ref({}) | |||||
| const summary = ref({ | |||||
| recharge: 0, | |||||
| consume: 0, | |||||
| reward: 0, | |||||
| refund: 0, | |||||
| adjust: 0, | |||||
| counts: { all: 0, recharge: 0, consume: 0, reward: 0, other: 0 }, | |||||
| }) | }) | ||||
| function formatDate(dateStr) { | |||||
| if (!dateStr) return '' | |||||
| return String(dateStr).substring(0, 10) | |||||
| } | |||||
| const numberFormatter = new Intl.NumberFormat('zh-CN') | |||||
| const balanceText = computed(() => { | |||||
| if (!isLogin.value) return '—' | |||||
| const points = balance.value.points ?? balance.value.point_balance ?? userInfo.value.balance | |||||
| return Number(points) < 0 ? '不限' : formatNumber(points) | |||||
| }) | |||||
| const balanceUnit = computed(() => (balanceText.value === '不限' || balanceText.value === '—' ? '' : '点')) | |||||
| const tabCounts = computed(() => summary.value.counts || {}) | |||||
| const hasMore = computed(() => list.value.length < total.value) | |||||
| const initialLoading = computed(() => loading.value && !list.value.length) | |||||
| const activeTabName = computed(() => { | |||||
| const item = tabs.find(tab => tab.key === activeTab.value) | |||||
| return item ? `${item.name}记录` : '全部记录' | |||||
| }) | |||||
| const summaryStats = computed(() => [ | |||||
| { | |||||
| key: 'recharge', | |||||
| label: '累计充值', | |||||
| helper: '历史充值获得', | |||||
| value: formatSigned(summary.value.recharge, true), | |||||
| icon: 'i-lucide-circle-plus', | |||||
| iconTone: 'bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]', | |||||
| valueTone: 'text-[var(--c-0879dc)]', | |||||
| }, | |||||
| { | |||||
| key: 'consume', | |||||
| label: '累计消费', | |||||
| helper: '创作累计使用', | |||||
| value: formatSigned(summary.value.consume ? -Math.abs(summary.value.consume) : 0), | |||||
| icon: 'i-lucide-sparkles', | |||||
| iconTone: 'bg-[var(--c-fff4ec)] text-[var(--c-e85f1c)]', | |||||
| valueTone: 'text-[var(--c-e85f1c)]', | |||||
| }, | |||||
| { | |||||
| key: 'reward', | |||||
| label: '累计奖励', | |||||
| helper: '活动与任务获得', | |||||
| value: formatSigned(summary.value.reward, true), | |||||
| icon: 'i-lucide-gift', | |||||
| iconTone: 'bg-[var(--c-edfdf4)] text-[var(--c-159a54)]', | |||||
| valueTone: 'text-[var(--c-159a54)]', | |||||
| }, | |||||
| ]) | |||||
| async function loadBills(reset = false) { | |||||
| async function fetchList(reset = false) { | |||||
| if (!isLogin.value || loading.value) return false | |||||
| if (reset) { | if (reset) { | ||||
| page.value = 1 | page.value = 1 | ||||
| bills.value = [] | |||||
| list.value = [] | |||||
| total.value = 0 | |||||
| } | } | ||||
| loading.value = true | loading.value = true | ||||
| loadError.value = '' | |||||
| try { | try { | ||||
| const res = await post('/user/getbills', { | |||||
| type: activeType.value === 'all' ? '' : activeType.value, | |||||
| page: page.value, | |||||
| page_size: 20, | |||||
| client: 1, | |||||
| const res = await post('/point/loglist', { | |||||
| type: activeTab.value, | |||||
| page_no: page.value, | |||||
| page_size: pageSize, | |||||
| }) | }) | ||||
| 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 { | |||||
| // 忽略 | |||||
| const rows = res.list || [] | |||||
| const data = res.data || {} | |||||
| list.value = reset ? rows : list.value.concat(rows) | |||||
| total.value = Number(data.total ?? rows.length) | |||||
| setBalance(data.balance || {}) | |||||
| summary.value = normalizeSummary(data.summary || {}) | |||||
| hasLoaded.value = true | |||||
| return true | |||||
| } catch (e) { | |||||
| loadError.value = e.message || '网络开小差了,请稍后重试。' | |||||
| toast.add({ title: loadError.value, color: 'error' }) | |||||
| return false | |||||
| } finally { | } finally { | ||||
| loading.value = false | loading.value = false | ||||
| } | } | ||||
| } | } | ||||
| function loadMore() { | |||||
| page.value++ | |||||
| loadBills(false) | |||||
| function normalizeSummary(data = {}) { | |||||
| const counts = Object.assign( | |||||
| { all: 0, recharge: 0, consume: 0, reward: 0, other: 0 }, | |||||
| data.counts || {}, | |||||
| ) | |||||
| Object.keys(counts).forEach((key) => { | |||||
| counts[key] = Number(counts[key] || 0) | |||||
| }) | |||||
| return { | |||||
| recharge: Number(data.recharge || 0), | |||||
| consume: Number(data.consume || 0), | |||||
| reward: Number(data.reward || 0), | |||||
| refund: Number(data.refund || 0), | |||||
| adjust: Number(data.adjust || 0), | |||||
| counts, | |||||
| } | |||||
| } | } | ||||
| watch(activeType, () => { | |||||
| loadBills(true) | |||||
| }) | |||||
| function setBalance(data = {}) { | |||||
| balance.value = data | |||||
| const points = data.points ?? data.point_balance | |||||
| if (points !== undefined) userInfo.value.balance = Number(points) || 0 | |||||
| } | |||||
| onMounted(() => { | |||||
| if (isLogin.value) { | |||||
| loadBills(true) | |||||
| function changeTab(key) { | |||||
| if (activeTab.value === key || loading.value) return | |||||
| activeTab.value = key | |||||
| fetchList(true) | |||||
| } | |||||
| async function loadMore() { | |||||
| if (loading.value || !hasMore.value) return | |||||
| page.value += 1 | |||||
| const success = await fetchList() | |||||
| if (!success) page.value -= 1 | |||||
| } | |||||
| function formatNumber(value) { | |||||
| const num = Number(value || 0) | |||||
| return numberFormatter.format(Number.isFinite(num) ? num : 0) | |||||
| } | |||||
| function formatSigned(value, plus = false) { | |||||
| const num = Number(value || 0) | |||||
| return num > 0 && plus ? `+${formatNumber(num)}` : formatNumber(num) | |||||
| } | |||||
| function formatAmount(value) { | |||||
| const num = Number(value || 0) | |||||
| return num > 0 ? `+${formatNumber(num)}` : formatNumber(num) | |||||
| } | |||||
| /** | |||||
| * 充值流水的订单号。 | |||||
| * 后端 point_log.biz_no 在充值时存的就是 point_order.order_no(消费存的是计费流水号,不展示)。 | |||||
| */ | |||||
| function orderNo(item) { | |||||
| if (!item || item.type !== 'recharge') return '' | |||||
| return String(item.biz_no || '').trim() | |||||
| } | |||||
| async function copyOrderNo(value) { | |||||
| if (!value) return | |||||
| try { | |||||
| await navigator.clipboard.writeText(value) | |||||
| toast.add({ title: '订单号已复制', color: 'success' }) | |||||
| } catch { | |||||
| // 非 HTTPS / 旧浏览器下 clipboard 不可用,退回到 execCommand | |||||
| const input = document.createElement('input') | |||||
| input.value = value | |||||
| document.body.appendChild(input) | |||||
| input.select() | |||||
| const ok = document.execCommand?.('copy') | |||||
| document.body.removeChild(input) | |||||
| toast.add({ title: ok ? '订单号已复制' : '复制失败,请手动选择', color: ok ? 'success' : 'error' }) | |||||
| } | |||||
| } | |||||
| function getTypeIcon(type) { | |||||
| const map = { | |||||
| recharge: 'i-lucide-wallet-cards', | |||||
| consume: 'i-lucide-sparkles', | |||||
| reward: 'i-lucide-gift', | |||||
| refund: 'i-lucide-rotate-ccw', | |||||
| adjust: 'i-lucide-sliders-horizontal', | |||||
| } | |||||
| return map[type] || 'i-lucide-receipt-text' | |||||
| } | |||||
| function typeTone(type) { | |||||
| const map = { | |||||
| recharge: 'bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-[var(--c-d9ecff)]', | |||||
| consume: 'bg-[var(--c-fff4ec)] text-[var(--c-e85f1c)] ring-[var(--c-ffe0cc)]', | |||||
| reward: 'bg-[var(--c-edfdf4)] text-[var(--c-159a54)] ring-[var(--c-d2f5e2)]', | |||||
| refund: 'bg-[var(--c-f3f0ff)] text-[var(--c-7253e5)] ring-[var(--c-e2dafd)]', | |||||
| adjust: 'bg-[var(--c-f3f0ff)] text-[var(--c-7253e5)] ring-[var(--c-e2dafd)]', | |||||
| } | |||||
| return map[type] || 'bg-[var(--c-f1f5fa)] text-[var(--c-66758a)] ring-[var(--c-e3eaf2)]' | |||||
| } | |||||
| function typeBadgeTone(type) { | |||||
| const map = { | |||||
| recharge: 'bg-[var(--c-eef7ff)] text-[var(--c-0879dc)]', | |||||
| consume: 'bg-[var(--c-fff4ec)] text-[var(--c-d95414)]', | |||||
| reward: 'bg-[var(--c-edfdf4)] text-[var(--c-14894d)]', | |||||
| refund: 'bg-[var(--c-f3f0ff)] text-[var(--c-6848d8)]', | |||||
| adjust: 'bg-[var(--c-f3f0ff)] text-[var(--c-6848d8)]', | |||||
| } | |||||
| return map[type] || 'bg-[var(--c-f1f5fa)] text-[var(--c-66758a)]' | |||||
| } | |||||
| function amountTone(value) { | |||||
| const amount = Number(value || 0) | |||||
| if (amount > 0) return 'text-[var(--c-159a54)]' | |||||
| if (amount < 0) return 'text-[var(--c-e85f1c)]' | |||||
| return 'text-[var(--c-66758a)]' | |||||
| } | |||||
| onMounted(() => fetchList(true)) | |||||
| watch(isLogin, (value) => { | |||||
| if (value) { | |||||
| fetchList(true) | |||||
| } else { | |||||
| list.value = [] | |||||
| total.value = 0 | |||||
| loadError.value = '' | |||||
| hasLoaded.value = false | |||||
| balance.value = {} | |||||
| } | } | ||||
| }) | }) | ||||
| </script> | </script> | ||||
| <style scoped> | |||||
| .bill-tabs { | |||||
| scrollbar-width: none; | |||||
| } | |||||
| .bill-tabs::-webkit-scrollbar { | |||||
| display: none; | |||||
| } | |||||
| </style> | |||||
| @@ -0,0 +1,14 @@ | |||||
| <template> | |||||
| <div /> | |||||
| </template> | |||||
| <script setup> | |||||
| import { articleUrl, useArticleDetail } from '~/composables/useArticle' | |||||
| // 兼容旧 sitemap 中的教程详情地址,并收敛到当前资讯规范路径。 | |||||
| const nuxtApp = useNuxtApp() | |||||
| const { detail } = await useArticleDetail() | |||||
| await nuxtApp.runWithContext(() => | |||||
| navigateTo(articleUrl(detail.value), { redirectCode: 301, replace: true })) | |||||
| </script> | |||||
| @@ -1,38 +1,45 @@ | |||||
| <template> | <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> | |||||
| <div class="min-h-screen bg-[var(--c-f3f7fb)]"> | |||||
| <!-- ========== Hero (统一使用 AppHero,保证各页高度/底色一致) ========== --> | |||||
| <AppHero | |||||
| kicker="ANIME AVATAR STUDIO" | |||||
| title="上传自拍,生成你的动漫头像" | |||||
| desc="用真实对比看效果。支持竖图、半身照和普通自拍,脸部清晰会更稳定。" | |||||
| > | |||||
| <template #actions> | |||||
| <button | |||||
| type="button" | |||||
| class="h-10 rounded-full bg-[var(--c-0b8cff)] px-5 text-sm font-extrabold text-white shadow-[0_10px_20px_var(--sh-11-140-255-220)] transition-colors hover:bg-[var(--c-0a7ce0)] lg:h-11" | |||||
| @click="openCompare" | |||||
| > | |||||
| 查看对比 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="h-10 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-0b8cff)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)] lg:h-11" | |||||
| @click="triggerUpload" | |||||
| > | |||||
| 上传照片 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="inline-flex h-10 items-center gap-1.5 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-5b6b80)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)] lg:h-11" | |||||
| @click="historyOpen = true" | |||||
| > | |||||
| <UIcon name="i-lucide-history" class="size-4" /> | |||||
| 历史记录 | |||||
| </button> | |||||
| </template> | |||||
| <template #aside> | |||||
| <HeroComparePreview | |||||
| v-if="demoPair.original && demoPair.anime" | |||||
| :before="demoPair.original" | |||||
| :after="demoPair.anime" | |||||
| after-label="动漫" | |||||
| @open="openCompare" | |||||
| /> | |||||
| </template> | |||||
| </AppHero> | |||||
| <!-- ========== Main Grid ========== --> | <!-- ========== Main Grid ========== --> | ||||
| <div class="max-w-7xl mx-auto px-4 sm:px-6 py-6 lg:py-8"> | <div class="max-w-7xl mx-auto px-4 sm:px-6 py-6 lg:py-8"> | ||||
| @@ -40,47 +47,47 @@ | |||||
| <!-- ===== LEFT: Form (col-span-7) ===== --> | <!-- ===== LEFT: Form (col-span-7) ===== --> | ||||
| <div class="lg:col-span-7 space-y-5 pb-24 lg:pb-0"> | <div class="lg:col-span-7 space-y-5 pb-24 lg:pb-0"> | ||||
| <!-- 1. 参考照片 Upload Card --> | <!-- 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="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)]"> | |||||
| <div class="flex items-start justify-between gap-4 mb-4"> | <div class="flex items-start justify-between gap-4 mb-4"> | ||||
| <div class="min-w-0"> | <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> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">参考照片</h3> | |||||
| <p class="mt-1 text-xs text-[var(--c-8a97a8)]">建议使用正面、上半身、脸部无遮挡的照片</p> | |||||
| </div> | </div> | ||||
| <span class="flex-shrink-0 px-3 py-1 rounded-full text-[11px] font-bold text-[#ff6b6b] bg-[#fff1f1]">必选</span> | |||||
| <span class="flex-shrink-0 px-3 py-1 rounded-full text-[11px] font-bold text-[var(--c-ff6b6b)] bg-[var(--c-fff1f1)]">必选</span> | |||||
| </div> | </div> | ||||
| <!-- Upload Strip --> | <!-- 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" | |||||
| <div class="rounded-2xl border-2 border-[var(--c-e5edf7)] bg-[var(--c-f7faff)] p-4 flex items-center gap-4 cursor-pointer hover:border-[var(--c-1f8cff)]/40 transition-colors" | |||||
| @click="triggerUpload" @dragover.prevent @drop.prevent="onDrop"> | @click="triggerUpload" @dragover.prevent @drop.prevent="onDrop"> | ||||
| <div class="w-20 h-28 rounded-2xl bg-[#edf3fb] overflow-hidden relative flex-shrink-0"> | |||||
| <div class="w-20 h-28 rounded-2xl bg-[var(--c-edf3fb)] overflow-hidden relative flex-shrink-0"> | |||||
| <img v-if="previewImage" :src="previewImage" class="w-full h-full object-cover" alt="preview" /> | <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"> | <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> | |||||
| <span class="w-9 h-9 rounded-full bg-[var(--c-1f8cff)] text-white text-2xl font-light flex items-center justify-center">+</span> | |||||
| </div> | </div> | ||||
| <div v-if="uploading" class="absolute inset-0 bg-[#111e34]/60 text-white text-xs flex items-center justify-center">上传中</div> | |||||
| <div v-if="uploading" class="absolute inset-0 bg-[var(--c-111e34)]/60 text-white text-xs flex items-center justify-center">上传中</div> | |||||
| </div> | </div> | ||||
| <div class="flex-1 min-w-0"> | <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 class="text-sm lg:text-base font-extrabold text-[var(--c-172033)]">{{ previewImage ? '已选择照片' : '选择一张清晰自拍' }}</div> | |||||
| <div class="mt-1.5 text-xs text-[var(--c-8a97a8)]">不用裁成正方形,竖图和半身照也可以</div> | |||||
| </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 class="w-16 h-9 rounded-full bg-[var(--c-1f8cff)] text-white text-xs font-extrabold flex items-center justify-center flex-shrink-0">{{ previewImage ? '更换' : '上传' }}</div> | |||||
| </div> | </div> | ||||
| <input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileChange" /> | <input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileChange" /> | ||||
| <!-- Tips --> | <!-- Tips --> | ||||
| <div class="mt-3 flex gap-2.5"> | <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> | |||||
| <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[var(--c-526174)] bg-[var(--c-f1f5fa)]">正面</span> | |||||
| <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[var(--c-526174)] bg-[var(--c-f1f5fa)]">脸部清晰</span> | |||||
| <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[var(--c-526174)] bg-[var(--c-f1f5fa)]">避免遮挡</span> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <!-- 2. 头像风格 --> | <!-- 2. 头像风格 --> | ||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]"> | |||||
| <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)]"> | |||||
| <div class="flex items-start justify-between gap-4"> | <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> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">头像风格</h3> | |||||
| <span class="text-xs text-[var(--c-8a97a8)] mt-1">点选切换</span> | |||||
| </div> | </div> | ||||
| <div v-if="styleLoading" class="mt-4 flex justify-center py-8"> | <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" /> | |||||
| <span class="w-6 h-6 border-2 border-[var(--c-1f8cff)] border-t-transparent rounded-full animate-spin" /> | |||||
| </div> | </div> | ||||
| <template v-else> | <template v-else> | ||||
| <!-- 风格网格:移动 3 列 / 桌面 4 列,默认 2 行折叠 --> | <!-- 风格网格:移动 3 列 / 桌面 4 列,默认 2 行折叠 --> | ||||
| @@ -88,19 +95,19 @@ | |||||
| <button v-for="s in visibleStyleList" :key="s.value" type="button" | <button v-for="s in visibleStyleList" :key="s.value" type="button" | ||||
| class="p-2 rounded-2xl border-2 transition-all text-center" | class="p-2 rounded-2xl border-2 transition-all text-center" | ||||
| :class="form.img2img_style === String(s.value) | :class="form.img2img_style === String(s.value) | ||||
| ? 'bg-[#eef7ff] border-[#1f8cff]' | |||||
| : 'bg-[#f6f9fd] border-transparent hover:border-[#e5edf7]'" | |||||
| ? 'bg-[var(--c-eef7ff)] border-[var(--c-1f8cff)]' | |||||
| : 'bg-[var(--c-f6f9fd)] border-transparent hover:border-[var(--c-e5edf7)]'" | |||||
| @click="selectStyle(s)"> | @click="selectStyle(s)"> | ||||
| <div class="w-full aspect-square rounded-xl overflow-hidden bg-[#eaf0f8] flex items-center justify-center"> | |||||
| <div class="w-full aspect-square rounded-xl overflow-hidden bg-[var(--c-eaf0f8)] flex items-center justify-center"> | |||||
| <img v-if="s.pic" :src="s.pic" class="w-full h-full object-cover" :alt="s.name" /> | <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> | |||||
| <span v-else class="text-xs text-[var(--c-77869a)]">{{ s.name }}</span> | |||||
| </div> | </div> | ||||
| <div class="mt-2 text-xs font-bold text-[#334155] truncate">{{ s.name }}</div> | |||||
| <div class="mt-2 text-xs font-bold text-[var(--c-334155)] truncate">{{ s.name }}</div> | |||||
| </button> | </button> | ||||
| </div> | </div> | ||||
| <!-- 展开/收起按钮 --> | <!-- 展开/收起按钮 --> | ||||
| <button v-if="hasMoreStyles" type="button" | <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" | |||||
| class="mt-3 w-full h-10 rounded-xl border border-[var(--c-e5edf7)] text-[var(--c-1f8cff)] text-sm font-bold hover:bg-[var(--c-eef7ff)] transition-colors flex items-center justify-center gap-1" | |||||
| @click="styleExpanded = !styleExpanded"> | @click="styleExpanded = !styleExpanded"> | ||||
| <span>{{ styleExpanded ? '收起' : '展开全部' }}</span> | <span>{{ styleExpanded ? '收起' : '展开全部' }}</span> | ||||
| <UIcon :name="styleExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="size-4" /> | <UIcon :name="styleExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="size-4" /> | ||||
| @@ -109,60 +116,66 @@ | |||||
| </div> | </div> | ||||
| <!-- 3. 自由度 --> | <!-- 3. 自由度 --> | ||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]"> | |||||
| <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)]"> | |||||
| <div class="flex items-start justify-between gap-4"> | <div class="flex items-start justify-between gap-4"> | ||||
| <div> | <div> | ||||
| <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">自由度</h3> | |||||
| <p class="mt-1 text-xs text-[#8a97a8]">{{ strengthCopy }}</p> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">自由度</h3> | |||||
| <p class="mt-1 text-xs text-[var(--c-8a97a8)]">{{ strengthCopy }}</p> | |||||
| </div> | </div> | ||||
| <span class="text-lg font-extrabold text-[#1f8cff]">{{ form.img2img_strength.toFixed(2) }}</span> | |||||
| <span class="text-lg font-extrabold text-[var(--c-1f8cff)]">{{ form.img2img_strength.toFixed(2) }}</span> | |||||
| </div> | </div> | ||||
| <input type="range" min="0.1" max="1" step="0.01" v-model.number="form.img2img_strength" | <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]"> | |||||
| class="mt-4 w-full h-2 rounded-full appearance-none bg-[var(--c-e7edf5)] accent-[var(--c-1f8cff)] cursor-pointer" /> | |||||
| <div class="mt-2 flex justify-between text-xs text-[var(--c-8a97a8)]"> | |||||
| <span>更像原图</span><span>更有创意</span> | <span>更像原图</span><span>更有创意</span> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <!-- 4. 画面偏好 --> | <!-- 4. 画面偏好 --> | ||||
| <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]"> | |||||
| <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)]"> | |||||
| <div class="flex items-start justify-between gap-4"> | <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> | |||||
| <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">画面偏好</h3> | |||||
| <span class="text-xs text-[var(--c-8a97a8)] mt-1">可选</span> | |||||
| </div> | </div> | ||||
| <div class="mt-4"> | <div class="mt-4"> | ||||
| <label class="block text-xs font-bold text-[#526174] mb-2">描述词</label> | |||||
| <label class="block text-xs font-bold text-[var(--c-526174)] mb-2">描述词</label> | |||||
| <input v-model="form.content" type="text" maxlength="100" placeholder="例如:浅色背景、清爽、微笑" | <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" /> | |||||
| class="w-full h-12 rounded-xl bg-[var(--c-f6f9fd)] px-4 text-sm text-[var(--c-172033)] placeholder:text-[var(--c-a0aabb)] focus:outline-none focus:ring-2 focus:ring-[var(--c-1f8cff)]/30" /> | |||||
| </div> | </div> | ||||
| <div class="mt-4"> | <div class="mt-4"> | ||||
| <label class="block text-xs font-bold text-[#526174] mb-2">反面提示词</label> | |||||
| <label class="block text-xs font-bold text-[var(--c-526174)] mb-2">反面提示词</label> | |||||
| <input v-model="form.no" type="text" maxlength="100" placeholder="例如:模糊、变形、低清晰度" | <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" /> | |||||
| class="w-full h-12 rounded-xl bg-[var(--c-f6f9fd)] px-4 text-sm text-[var(--c-172033)] placeholder:text-[var(--c-a0aabb)] focus:outline-none focus:ring-2 focus:ring-[var(--c-1f8cff)]/30" /> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <!-- 5. Tips --> | <!-- 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="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)] space-y-3"> | |||||
| <div class="flex items-center"> | <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> | |||||
| <span class="w-7 h-5 mr-3 rounded-[14px] bg-[var(--c-eef7ff)] text-[var(--c-1f8cff)] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">01</span> | |||||
| <span class="text-sm text-[var(--c-5f6f83)] leading-relaxed">上传照片后可直接生成,处理完成会进入作品详情页。</span> | |||||
| </div> | </div> | ||||
| <div class="flex items-center"> | <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> | |||||
| <span class="w-7 h-5 mr-3 rounded-[14px] bg-[var(--c-eef7ff)] text-[var(--c-1f8cff)] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">02</span> | |||||
| <span class="text-sm text-[var(--c-5f6f83)] leading-relaxed">历史记录里可以查看生成进度,也可以继续打开已完成作品。</span> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <!-- Desktop Submit --> | <!-- 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="hidden lg:flex items-center gap-3 rounded-2xl bg-[var(--c-ffffff)] p-4 shadow-[0_10px_28px_var(--sh-32-56-92-50)]"> | |||||
| <div class="flex-1 min-w-0"> | <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 class="text-sm font-bold text-[var(--c-172033)] whitespace-nowrap">{{ balanceText }}</div> | |||||
| <div v-if="isBalanceInsufficient" class="mt-1 text-xs text-[var(--c-f04438)]">点数不足,请前往充值</div> | |||||
| </div> | </div> | ||||
| <button type="button" | <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'" | |||||
| class="h-14 flex-shrink-0 rounded-full border border-[var(--c-e5edf7)] px-6 text-sm font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-f7f9fd)] flex items-center gap-1.5" | |||||
| @click="historyOpen = true"> | |||||
| <UIcon name="i-lucide-history" class="size-4" /> | |||||
| 历史记录 | |||||
| </button> | |||||
| <button type="button" | |||||
| class="px-10 h-14 rounded-full text-white font-bold text-base shadow-[0_12px_24px_var(--sh-31-140-255-280)] transition-all flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-w-[160px]" | |||||
| :class="submitting ? 'bg-[var(--c-1979e6)]' : 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] hover:opacity-90'" | |||||
| :disabled="!canSubmit || submitting" @click="handleSubmit"> | :disabled="!canSubmit || submitting" @click="handleSubmit"> | ||||
| <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" /> | <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" /> | ||||
| <span>{{ submitting ? '生成中...' : submitText }}</span> | <span>{{ submitting ? '生成中...' : submitText }}</span> | ||||
| @@ -172,61 +185,74 @@ | |||||
| <!-- ===== RIGHT: Result (col-span-5, sticky) ===== --> | <!-- ===== RIGHT: Result (col-span-5, sticky) ===== --> | ||||
| <div class="lg:col-span-5"> | <div class="lg:col-span-5"> | ||||
| <div class="lg:sticky lg:top-6 space-y-4"> | |||||
| <!-- 吸顶偏移 = header 高度(64px) + 间距,避免被导航遮挡 --> | |||||
| <div class="space-y-4 lg:sticky lg:top-20 lg:max-h-[calc(100vh-6rem)] lg:overflow-y-auto"> | |||||
| <!-- Empty --> | <!-- 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 v-if="!resultState.visible" class="bg-[var(--c-ffffff)] 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-[var(--c-a0aabb)] mb-4" /> | |||||
| <div class="text-base font-medium text-[var(--c-172033)] mb-1">等待生成</div> | |||||
| <div class="text-sm text-[var(--c-a0aabb)]">上传照片并选择风格后</div> | |||||
| <div class="text-sm text-[var(--c-a0aabb)]">点击"立即生成"即可看到效果</div> | |||||
| </div> | </div> | ||||
| <!-- Loading --> | <!-- 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 v-if="resultState.loading" class="bg-[var(--c-ffffff)] rounded-2xl p-6 lg:p-8 shadow-sm"> | |||||
| <div class="text-base font-bold text-[var(--c-172033)] mb-1">AI 正在创作中</div> | |||||
| <div class="text-sm text-[var(--c-7f8896)] mb-6">预计需要 10-30 秒,请耐心等待...</div> | |||||
| <div class="rounded-2xl bg-[var(--c-f7f9fd)] py-10 flex flex-col items-center"> | |||||
| <div class="flex gap-1.5 mb-4"> | <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" /> | |||||
| <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-1f8cff)] animate-pulse-dot" /> | |||||
| <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-1f8cff)] animate-pulse-dot2" /> | |||||
| <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-1f8cff)] animate-pulse-dot3" /> | |||||
| </div> | </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 class="text-sm font-bold text-[var(--c-172033)]">正在生成你的动漫头像</div> | |||||
| <div v-if="pollCountdown > 0" class="mt-2 text-xs text-[var(--c-8a95a6)]">下次刷新 {{ pollCountdown }}s</div> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <!-- Error --> | <!-- Error --> | ||||
| <div v-if="resultState.error" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10"> | |||||
| <div v-if="resultState.error" class="bg-[var(--c-ffffff)] 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" /> | <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> | |||||
| <div class="text-base font-medium text-[var(--c-f04438)] mb-2">生成失败</div> | |||||
| <div class="text-sm text-[var(--c-7f8896)] mb-4">{{ resultState.errorMsg }}</div> | |||||
| <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton> | <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton> | ||||
| </div> | </div> | ||||
| <!-- Success --> | <!-- Success --> | ||||
| <div v-if="resultState.success" class="space-y-4"> | <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="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-sm"> | |||||
| <div class="text-base font-bold text-[var(--c-172033)] mb-4">效果对比</div> | |||||
| <div class="grid grid-cols-2 gap-3"> | <div class="grid grid-cols-2 gap-3"> | ||||
| <div> | <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 class="text-xs text-[var(--c-8a95a6)] mb-2">原图</div> | |||||
| <img v-if="previewImage" :src="previewImage" class="w-full aspect-square rounded-xl object-cover bg-[var(--c-f7f9fd)]" alt="original" /> | |||||
| </div> | </div> | ||||
| <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 class="text-xs text-[var(--c-8a95a6)] mb-2">动漫头像</div> | |||||
| <div class="flex aspect-square w-full items-center justify-center overflow-hidden rounded-xl border-2 border-[var(--c-1f8cff)] bg-[var(--c-f7f9fd)]"> | |||||
| <img | |||||
| v-if="genResultUrl && !resultImageFailed" | |||||
| :src="genResultUrl" | |||||
| class="h-full w-full object-cover" | |||||
| alt="动漫头像生成结果" | |||||
| @error="handleResultImageError" | |||||
| > | |||||
| <div v-else class="px-4 text-center"> | |||||
| <UIcon name="i-lucide-image-off" class="mx-auto mb-2 size-8 text-[var(--c-a0aabb)]" /> | |||||
| <div class="text-xs leading-relaxed text-[var(--c-7f8896)]">结果图片暂时无法加载</div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <div class="mt-5 flex gap-3"> | <div class="mt-5 flex gap-3"> | ||||
| <button type="button" | <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" | |||||
| class="flex-1 h-12 rounded-full border-2 border-[var(--c-1f8cff)] text-[var(--c-1f8cff)] font-bold hover:bg-[var(--c-eef7ff)] transition-colors flex items-center justify-center" | |||||
| @click="openResultCompare"> | @click="openResultCompare"> | ||||
| <UIcon name="i-lucide-split-square-horizontal" class="size-4 mr-1.5" /> | <UIcon name="i-lucide-split-square-horizontal" class="size-4 mr-1.5" /> | ||||
| 对比查看 | 对比查看 | ||||
| </button> | </button> | ||||
| <button type="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" | |||||
| class="flex-1 h-12 rounded-full bg-[var(--c-1f8cff)] hover:bg-[var(--c-1979e6)] text-white font-bold text-base shadow-[0_8px_20px_var(--sh-31-140-255-200)] transition-colors flex items-center justify-center" | |||||
| @click="downloadResult"> | @click="downloadResult"> | ||||
| <UIcon name="i-lucide-download" class="size-4 mr-1.5" /> | <UIcon name="i-lucide-download" class="size-4 mr-1.5" /> | ||||
| 下载头像 | 下载头像 | ||||
| @@ -235,7 +261,7 @@ | |||||
| </div> | </div> | ||||
| <div class="flex gap-3"> | <div class="flex gap-3"> | ||||
| <button type="button" | <button type="button" | ||||
| class="flex-1 h-12 rounded-full border-2 border-[#1f8cff] text-[#1f8cff] font-bold hover:bg-[#eef7ff] transition-colors" | |||||
| class="flex-1 h-12 rounded-full border-2 border-[var(--c-1f8cff)] text-[var(--c-1f8cff)] font-bold hover:bg-[var(--c-eef7ff)] transition-colors" | |||||
| :disabled="submitting" @click="handleSubmit">重新生成</button> | :disabled="submitting" @click="handleSubmit">重新生成</button> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -245,16 +271,16 @@ | |||||
| </div> | </div> | ||||
| <!-- ========== Mobile Bottom Bar ========== --> | <!-- ========== 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)]" | |||||
| <div class="lg:hidden fixed left-0 right-0 bottom-0 z-50 bg-[var(--c-ffffff)]/95 backdrop-blur shadow-[0_-10px_30px_var(--sh-32-56-92-100)]" | |||||
| style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))"> | 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="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 class="min-w-0 truncate text-[var(--c-7b8797)]">{{ balanceText }}</div> | |||||
| <button v-if="isBalanceInsufficient" class="text-[var(--c-1f8cff)] text-xs font-extrabold flex-shrink-0" @click="goRecharge">去充值</button> | |||||
| </div> | </div> | ||||
| <div class="flex gap-3"> | <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'" | |||||
| <button class="w-[120px] h-12 rounded-full bg-[var(--c-eef7ff)] text-[var(--c-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_var(--sh-31-140-255-280)] transition-opacity flex items-center justify-center" | |||||
| :class="(canSubmit && !submitting) ? 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)]' : 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] opacity-60'" | |||||
| :disabled="!canSubmit || submitting" @click="handleSubmit"> | :disabled="!canSubmit || submitting" @click="handleSubmit"> | ||||
| <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" /> | <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" /> | ||||
| {{ submitting ? '生成中...' : submitText }} | {{ submitting ? '生成中...' : submitText }} | ||||
| @@ -262,18 +288,22 @@ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <!-- ========== 历史记录抽屉 + 详情弹窗 ========== --> | |||||
| <WorkHistoryDrawer v-model="historyOpen" type="anime" @select="openHistoryItem" /> | |||||
| <WorkDetailModal v-model="detailOpen" :work="currentWork" /> | |||||
| <!-- ========== Compare Modal (大图滑块对比,支持 demo 和生成结果) ========== --> | <!-- ========== Compare Modal (大图滑块对比,支持 demo 和生成结果) ========== --> | ||||
| <Teleport to="body"> | <Teleport to="body"> | ||||
| <Transition name="modal"> | <Transition name="modal"> | ||||
| <div v-if="compareVisible" class="fixed inset-0 z-[100] flex items-center justify-center p-4"> | <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 class="absolute inset-0 bg-[var(--c-0f172a)]/60" @click="closeCompare" /> | |||||
| <div class="relative bg-[var(--c-ffffff)] 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-[var(--c-f3f4f6)]"> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)]">{{ compareTarget === 'result' ? '生成结果对比' : '效果对比' }}</h3> | |||||
| <button class="text-[var(--c-a0aabb)] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--c-f3f4f6)]" @click="closeCompare">×</button> | |||||
| </div> | </div> | ||||
| <div class="p-5"> | <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" | |||||
| <div ref="largeCompareRef" class="relative w-full aspect-[3/4] max-h-[70vh] rounded-2xl overflow-hidden bg-[var(--c-dfe8f4)] cursor-ew-resize select-none" | |||||
| @mousedown="onCompareStart" @touchstart.passive="onCompareStart"> | @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" /> | <img v-if="comparePair.anime" :src="comparePair.anime" class="absolute inset-0 w-full h-full object-cover pointer-events-none" alt="anime" /> | ||||
| @@ -281,15 +311,15 @@ | |||||
| <img v-if="comparePair.original" :src="comparePair.original" class="absolute inset-0 w-full h-full object-cover pointer-events-none" | <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" /> | :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"> | |||||
| <div class="absolute top-0 bottom-0 w-1 -ml-px bg-[var(--c-ffffff)]/95 shadow-[0_0_12px_var(--sh-20-37-66-220)] 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-[var(--c-ffffff)] shadow-[0_8px_18px_var(--sh-20-37-66-220)] flex items-center justify-center text-[var(--c-1f8cff)] text-lg font-extrabold gap-px"> | |||||
| <span>‹</span><span>›</span> | <span>‹</span><span>›</span> | ||||
| </div> | </div> | ||||
| </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 class="absolute top-3 left-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[var(--c-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-[var(--c-0f172a)]/50 pointer-events-none">{{ compareTarget === 'result' ? '生成图' : '动漫图' }}</div> | |||||
| </div> | </div> | ||||
| <div class="mt-3 text-center text-xs text-[#64748b]">左右拖动中间按钮查看变化</div> | |||||
| <div class="mt-3 text-center text-xs text-[var(--c-64748b)]">左右拖动中间按钮查看变化</div> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -303,6 +333,15 @@ import { computed, reactive, ref, onMounted, onUnmounted } from 'vue' | |||||
| const _api = useApi() | const _api = useApi() | ||||
| const api = _api // _api 是 { get, post, request },这里直接别名保持调用方式不变 | const api = _api // _api 是 { get, post, request },这里直接别名保持调用方式不变 | ||||
| await useLegacyAppSeo({ | |||||
| appKey: 'anime_avatar', | |||||
| fallbackTitle: 'AI漫画头像-动漫头像生成', | |||||
| fallbackDescription: 'AI生成漫画头像是一种新兴的技术,人工智能通过用户上传的图像处理技术,可以快速生成具有卡通、动漫等不同风格的头像。这种技术可以应用于社交媒体、游戏和虚拟现实等领域,为用户提供个性化、有趣的头像选择。', | |||||
| fallbackKeywords: '漫画头像、动漫头像、漫画头像生成、ai动漫头像', | |||||
| canonicalPath: '/draw/anime/', | |||||
| }) | |||||
| const user = useUser() | const user = useUser() | ||||
| const router = useRouter() | const router = useRouter() | ||||
| const { uploadImage } = useUpload() | const { uploadImage } = useUpload() | ||||
| @@ -342,6 +381,7 @@ const demoPair = ref({ original: '', anime: '' }) | |||||
| const resultState = ref({ visible: false, loading: false, error: false, success: false, errorMsg: '' }) | const resultState = ref({ visible: false, loading: false, error: false, success: false, errorMsg: '' }) | ||||
| const genResultUrl = ref('') | const genResultUrl = ref('') | ||||
| const resultImageFailed = ref(false) | |||||
| let pollTimer = null | let pollTimer = null | ||||
| let countTimer = null | let countTimer = null | ||||
| @@ -468,6 +508,8 @@ async function loadBalance() { | |||||
| const r = await api.post('/user/getbalance', {}) | const r = await api.post('/user/getbalance', {}) | ||||
| if (r.code === 0 && r.data) balance.points = r.data.points || r.data.point_balance || 0 | 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) } | } catch (e) { console.error('[anime-avatar] loadBalance failed:', e) } | ||||
| // 同步 header 显示的点数 | |||||
| user.refreshBalance() | |||||
| } | } | ||||
| onMounted(() => { | onMounted(() => { | ||||
| @@ -478,6 +520,16 @@ onMounted(() => { | |||||
| }) | }) | ||||
| onUnmounted(() => clearPoll()) | onUnmounted(() => clearPoll()) | ||||
| // ==================== 历史记录 / 详情 ==================== | |||||
| const historyOpen = ref(false) | |||||
| const detailOpen = ref(false) | |||||
| const currentWork = ref({}) | |||||
| function openHistoryItem(item) { | |||||
| currentWork.value = item | |||||
| detailOpen.value = true | |||||
| } | |||||
| // ==================== Compare Modal ==================== | // ==================== Compare Modal ==================== | ||||
| const compareVisible = ref(false) | const compareVisible = ref(false) | ||||
| const comparePercent = ref(50) | const comparePercent = ref(50) | ||||
| @@ -499,7 +551,14 @@ function openCompare() { | |||||
| compareVisible.value = true | compareVisible.value = true | ||||
| } | } | ||||
| function openResultCompare() { | function openResultCompare() { | ||||
| if (!previewImage.value || !genResultUrl.value) return | |||||
| if (!previewImage.value) { | |||||
| showToast('原图暂不可用,请重新上传') | |||||
| return | |||||
| } | |||||
| if (!genResultUrl.value || resultImageFailed.value) { | |||||
| showToast('结果图片暂不可用,请稍后重试') | |||||
| return | |||||
| } | |||||
| comparePercent.value = 50 | comparePercent.value = 50 | ||||
| compareTarget.value = 'result' | compareTarget.value = 'result' | ||||
| compareVisible.value = true | compareVisible.value = true | ||||
| @@ -533,6 +592,31 @@ function updateComparePercent(e) { | |||||
| comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1)))) | comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1)))) | ||||
| } | } | ||||
| function resolveResultImage(data = {}) { | |||||
| const candidates = [ | |||||
| data.content, | |||||
| data.answer, | |||||
| data.result_url, | |||||
| data.resultUrl, | |||||
| data.image_url, | |||||
| data.image, | |||||
| data.url, | |||||
| ] | |||||
| for (const candidate of candidates) { | |||||
| if (typeof candidate === 'string' && candidate.trim()) return candidate.trim() | |||||
| if (candidate && typeof candidate === 'object') { | |||||
| const nested = candidate.url || candidate.image_url || candidate.result_url | |||||
| if (typeof nested === 'string' && nested.trim()) return nested.trim() | |||||
| } | |||||
| } | |||||
| return '' | |||||
| } | |||||
| function handleResultImageError() { | |||||
| resultImageFailed.value = true | |||||
| showToast('结果图片加载失败,请稍后重试') | |||||
| } | |||||
| // ==================== Submit ==================== | // ==================== Submit ==================== | ||||
| function clearPoll() { | function clearPoll() { | ||||
| clearTimeout(pollTimer) | clearTimeout(pollTimer) | ||||
| @@ -548,8 +632,20 @@ async function startPoll() { | |||||
| const d = r.data || {} | const d = r.data || {} | ||||
| const s = Number(d.is_suc) | const s = Number(d.is_suc) | ||||
| if (s > 0) { | if (s > 0) { | ||||
| genResultUrl.value = d.answer || d.result_url || d.url || '' | |||||
| resultState.value = { visible: true, loading: false, error: false, success: true, errorMsg: '' } | |||||
| const resultUrl = resolveResultImage(d) | |||||
| if (!resultUrl) { | |||||
| resultState.value = { | |||||
| visible: true, | |||||
| loading: false, | |||||
| error: true, | |||||
| success: false, | |||||
| errorMsg: '生成已完成,但未获取到结果图片,请稍后重试', | |||||
| } | |||||
| } else { | |||||
| genResultUrl.value = resultUrl | |||||
| resultImageFailed.value = false | |||||
| resultState.value = { visible: true, loading: false, error: false, success: true, errorMsg: '' } | |||||
| } | |||||
| clearPoll() | clearPoll() | ||||
| loadBalance() | loadBalance() | ||||
| } else if (s < 0) { | } else if (s < 0) { | ||||
| @@ -573,6 +669,7 @@ async function handleSubmit() { | |||||
| submitting.value = true | submitting.value = true | ||||
| resultState.value = { visible: true, loading: true, error: false, success: false, errorMsg: '' } | resultState.value = { visible: true, loading: true, error: false, success: false, errorMsg: '' } | ||||
| genResultUrl.value = '' | genResultUrl.value = '' | ||||
| resultImageFailed.value = false | |||||
| try { | try { | ||||
| const params = { ...form, imgs: form.imgs.slice(0, 1).join(',') } | const params = { ...form, imgs: form.imgs.slice(0, 1).join(',') } | ||||
| @@ -610,7 +707,10 @@ async function handleSubmit() { | |||||
| } | } | ||||
| async function downloadResult() { | async function downloadResult() { | ||||
| if (!genResultUrl.value) return | |||||
| if (!genResultUrl.value || resultImageFailed.value) { | |||||
| showToast('结果图片暂不可用,请稍后重试') | |||||
| return | |||||
| } | |||||
| try { | try { | ||||
| const r = await fetch(genResultUrl.value) | const r = await fetch(genResultUrl.value) | ||||
| const b = await r.blob() | const b = await r.blob() | ||||
| @@ -625,10 +725,8 @@ async function downloadResult() { | |||||
| } | } | ||||
| function goRecharge() { router.push('/recharge') } | function goRecharge() { router.push('/recharge') } | ||||
| function goHistory() { | |||||
| if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return } | |||||
| router.push('/works') | |||||
| } | |||||
| // 历史记录改为抽屉,不再跳转页面 | |||||
| function goHistory() { historyOpen.value = true } | |||||
| </script> | </script> | ||||
| <style scoped> | <style scoped> | ||||
| @@ -0,0 +1,392 @@ | |||||
| <template> | |||||
| <div class="min-h-screen bg-[var(--c-f3f7fb)]"> | |||||
| <AppHero | |||||
| kicker="EARN POINTS" | |||||
| title="获得点数" | |||||
| desc="完成下面的任务即可获得点数,点数在小程序、H5 与 PC 端共享,可用于全部 AI 工具。" | |||||
| > | |||||
| <template #actions> | |||||
| <NuxtLink | |||||
| to="/bill" | |||||
| class="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-5b6b80)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)] lg:h-11" | |||||
| > | |||||
| <UIcon name="i-lucide-receipt" class="size-4" /> | |||||
| 点数明细 | |||||
| </NuxtLink> | |||||
| <NuxtLink | |||||
| to="/recharge" | |||||
| class="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-[var(--c-0b8cff)] px-5 text-sm font-extrabold text-white shadow-[0_10px_20px_var(--sh-11-140-255-220)] transition-colors hover:bg-[var(--c-0a7ce0)] lg:h-11" | |||||
| > | |||||
| <UIcon name="i-lucide-coins" class="size-4" /> | |||||
| 去充值 | |||||
| </NuxtLink> | |||||
| </template> | |||||
| <template #aside> | |||||
| <div class="w-full overflow-hidden rounded-3xl bg-gradient-to-br from-[var(--c-0758c9)] via-[var(--c-0b8cff)] to-[var(--c-54c8ff)] p-5 text-white shadow-[0_20px_42px_var(--sh-11-126-225-240)] lg:w-[300px] lg:p-6"> | |||||
| <div class="flex items-center gap-2 text-xs font-bold text-white/75"> | |||||
| <UIcon name="i-lucide-gift" class="size-4" /> | |||||
| 我的点数 | |||||
| </div> | |||||
| <div class="mt-4 text-4xl font-extrabold tabular-nums">{{ balance }}</div> | |||||
| <div class="mt-2 text-xs text-white/70">点数长期有效,多端共享</div> | |||||
| <div class="mt-5 flex items-center gap-1.5 border-t border-white/15 pt-4 text-[11px] font-medium text-white/70"> | |||||
| <UIcon name="i-lucide-shield-check" class="size-3.5" /> | |||||
| 任务完成后自动到账 | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| </AppHero> | |||||
| <main class="mx-auto max-w-7xl px-4 py-7 sm:px-6 lg:px-8 lg:py-10"> | |||||
| <!-- 未登录 --> | |||||
| <section | |||||
| v-if="!isLogin" | |||||
| class="rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] px-6 py-14 text-center shadow-[0_16px_40px_var(--sh-34-68-112-60)]" | |||||
| > | |||||
| <div class="mx-auto flex size-16 items-center justify-center rounded-2xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-8 ring-[var(--c-f7fbff)]"> | |||||
| <UIcon name="i-lucide-gift" class="size-7" /> | |||||
| </div> | |||||
| <h2 class="mt-6 text-xl font-extrabold text-[var(--c-172033)]">登录后完成任务获得点数</h2> | |||||
| <p class="mx-auto mt-2 max-w-md text-sm leading-6 text-[var(--c-66758a)]"> | |||||
| 登录后可以每日签到、邀请好友或扫码看视频获得点数,点数与小程序端共享。 | |||||
| </p> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-7 inline-flex h-11 cursor-pointer items-center gap-2 rounded-full bg-[var(--c-0b8cff)] px-7 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-240)] transition-colors hover:bg-[var(--c-087dde)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2" | |||||
| @click="showLogin = true" | |||||
| > | |||||
| <UIcon name="i-lucide-log-in" class="size-4" /> | |||||
| 立即登录 | |||||
| </button> | |||||
| </section> | |||||
| <template v-else> | |||||
| <!-- 骨架 --> | |||||
| <div v-if="loading && !config" class="grid gap-5 lg:grid-cols-2" role="status" aria-label="正在加载任务"> | |||||
| <div class="h-[260px] animate-pulse rounded-3xl bg-[var(--c-ffffff)] motion-reduce:animate-none" /> | |||||
| <div class="h-[260px] animate-pulse rounded-3xl bg-[var(--c-ffffff)] motion-reduce:animate-none" /> | |||||
| </div> | |||||
| <template v-else> | |||||
| <!-- 到账提示 --> | |||||
| <Transition name="tip"> | |||||
| <div | |||||
| v-if="arrivedPoints" | |||||
| class="mb-5 flex items-center gap-2 rounded-2xl border border-[var(--c-b7ecd0)] bg-[var(--c-edfdf4)] px-4 py-3 text-sm font-bold text-[var(--c-159a54)]" | |||||
| role="status" | |||||
| > | |||||
| <UIcon name="i-lucide-circle-check" class="size-4" /> | |||||
| 点数 +{{ arrivedPoints }} 已到账,当前 {{ balance }} 点 | |||||
| </div> | |||||
| </Transition> | |||||
| <!-- 每日签到:低成本任务放在最前,单一主操作可直接完成 --> | |||||
| <section | |||||
| v-if="checkinTask.enabled" | |||||
| class="mb-5 overflow-hidden rounded-3xl border border-[var(--c-dbeafe)] bg-gradient-to-br from-[var(--c-f4faff)] via-[var(--c-ffffff)] to-[var(--c-edfdf4)] p-5 shadow-[0_16px_40px_var(--sh-34-68-112-60)] lg:flex lg:items-center lg:gap-6 lg:p-6" | |||||
| aria-labelledby="checkin-task-title" | |||||
| > | |||||
| <div class="flex min-w-0 flex-1 items-start gap-4"> | |||||
| <div class="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-8 ring-[var(--c-f7fbff)]"> | |||||
| <UIcon name="i-lucide-calendar-check-2" class="size-6" /> | |||||
| </div> | |||||
| <div class="min-w-0"> | |||||
| <div class="flex flex-wrap items-center gap-2"> | |||||
| <h2 id="checkin-task-title" class="text-lg font-extrabold text-[var(--c-172033)]"> | |||||
| {{ pointCopy(checkinTask.title, '每日签到') }} | |||||
| </h2> | |||||
| <span class="rounded-full bg-[var(--c-fff5e8)] px-2.5 py-1 text-xs font-extrabold text-[var(--c-ff8a00)]"> | |||||
| +{{ checkinTask.points_per_checkin }} / 天 | |||||
| </span> | |||||
| </div> | |||||
| <p class="mt-1 text-sm leading-relaxed text-[var(--c-66758a)]"> | |||||
| {{ pointCopy(checkinTask.desc, '每天签到一次,点数立即到账') }} | |||||
| </p> | |||||
| <div class="mt-3 flex items-center gap-1.5 text-xs font-bold" :class="checkinTask.checked_in ? 'text-[var(--c-159a54)]' : 'text-[var(--c-0b8cff)]'"> | |||||
| <UIcon :name="checkinTask.checked_in ? 'i-lucide-circle-check' : 'i-lucide-clock-3'" class="size-4" /> | |||||
| {{ checkinTask.checked_in ? '今日已完成,明天再来' : '今日尚未签到' }} | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-5 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] px-7 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)] transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-[var(--c-d9e3ef)] disabled:shadow-none disabled:opacity-100 lg:mt-0 lg:w-auto" | |||||
| :disabled="checkinLoading || checkinTask.checked_in" | |||||
| @click="claimCheckin" | |||||
| > | |||||
| <span v-if="checkinLoading" class="size-4 animate-spin rounded-full border-2 border-white/35 border-t-white motion-reduce:animate-none" /> | |||||
| <UIcon v-else :name="checkinTask.checked_in ? 'i-lucide-check' : 'i-lucide-calendar-plus'" class="size-4" /> | |||||
| {{ checkinLoading ? '签到中...' : (checkinTask.checked_in ? '今日已签到' : '立即签到') }} | |||||
| </button> | |||||
| </section> | |||||
| <div class="grid gap-5 lg:grid-cols-2"> | |||||
| <!-- 看广告:PC 无激励视频,扫码去小程序看 --> | |||||
| <section | |||||
| v-if="adTask.enabled" | |||||
| class="flex h-full flex-col rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_16px_40px_var(--sh-34-68-112-60)] lg:p-6" | |||||
| aria-labelledby="ad-task-title" | |||||
| > | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <div class="min-w-0"> | |||||
| <h2 id="ad-task-title" class="text-lg font-extrabold text-[var(--c-172033)]"> | |||||
| {{ pointCopy(adTask.title, '看视频获得点数') }} | |||||
| </h2> | |||||
| <p class="mt-1 text-xs leading-relaxed text-[var(--c-8a97a8)]"> | |||||
| {{ pointCopy(adTask.desc, '完整观看一段视频即可获得点数') }} | |||||
| </p> | |||||
| </div> | |||||
| <span class="shrink-0 rounded-full bg-[var(--c-fff5e8)] px-3 py-1 text-xs font-extrabold text-[var(--c-ff8a00)]"> | |||||
| +{{ adTask.points_per_ad }} / 次 | |||||
| </span> | |||||
| </div> | |||||
| <div class="mt-5 flex flex-1 flex-col gap-5 sm:flex-row sm:items-center"> | |||||
| <div class="mx-auto shrink-0 sm:mx-0"> | |||||
| <div class="flex size-[168px] items-center justify-center overflow-hidden rounded-2xl border border-[var(--c-e6edf5)] bg-[var(--c-f8fbff)] p-2"> | |||||
| <img v-if="qrcodeUrl" :src="qrcodeUrl" class="size-full object-contain" alt="小程序码,扫码看视频获得点数"> | |||||
| <span v-else-if="qrLoading" class="size-7 animate-spin rounded-full border-2 border-[var(--c-0b8cff)]/25 border-t-[var(--c-0b8cff)] motion-reduce:animate-none" /> | |||||
| <div v-else class="flex flex-col items-center gap-2 text-center text-xs leading-5 text-[var(--c-8a95a6)]"> | |||||
| <div class="flex size-11 items-center justify-center rounded-xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]"> | |||||
| <UIcon name="i-lucide-qr-code" class="size-6" /> | |||||
| </div> | |||||
| <span>点击右侧按钮生成</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <div class="flex min-w-0 flex-1 flex-col"> | |||||
| <ol class="space-y-2 text-sm text-[var(--c-5f6f83)]"> | |||||
| <li class="flex gap-2"> | |||||
| <span class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-[var(--c-eef7ff)] text-[11px] font-extrabold text-[var(--c-0b8cff)]">1</span> | |||||
| {{ qrcodeUrl ? '微信扫描左侧小程序码' : '点击下方按钮生成小程序码' }} | |||||
| </li> | |||||
| <li class="flex gap-2"> | |||||
| <span class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-[var(--c-eef7ff)] text-[11px] font-extrabold text-[var(--c-0b8cff)]">2</span> | |||||
| 用同一账号登录小程序,完整看完视频 | |||||
| </li> | |||||
| <li class="flex gap-2"> | |||||
| <span class="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-[var(--c-eef7ff)] text-[11px] font-extrabold text-[var(--c-0b8cff)]">3</span> | |||||
| 点数自动到账,本页会自动刷新 | |||||
| </li> | |||||
| </ol> | |||||
| <div class="mt-4 text-xs text-[var(--c-8a97a8)]">{{ adProgressText }}</div> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-3 inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-full bg-[var(--c-0b8cff)] px-6 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)] transition-colors hover:bg-[var(--c-087dde)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" | |||||
| :disabled="qrLoading" | |||||
| @click="loadQrcode" | |||||
| > | |||||
| <UIcon name="i-lucide-qr-code" class="size-4" /> | |||||
| {{ qrcodeUrl ? '刷新小程序码' : '生成小程序码' }} | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| </section> | |||||
| <!-- 邀请好友 --> | |||||
| <section | |||||
| v-if="inviteTask.enabled" | |||||
| class="flex h-full flex-col rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_16px_40px_var(--sh-34-68-112-60)] lg:p-6" | |||||
| aria-labelledby="invite-task-title" | |||||
| > | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <div class="min-w-0"> | |||||
| <h2 id="invite-task-title" class="text-lg font-extrabold text-[var(--c-172033)]">邀请好友注册</h2> | |||||
| <p class="mt-1 text-xs leading-relaxed text-[var(--c-8a97a8)]"> | |||||
| 好友注册成功后,你获得 {{ inviteTask.inviter_points }} 点,好友也获得 {{ inviteTask.invitee_points }} 点 | |||||
| </p> | |||||
| </div> | |||||
| <span class="shrink-0 rounded-full bg-[var(--c-edfdf4)] px-3 py-1 text-xs font-extrabold text-[var(--c-159a54)]"> | |||||
| +{{ inviteTask.inviter_points }} / 人 | |||||
| </span> | |||||
| </div> | |||||
| <div class="mt-5 grid grid-cols-2 gap-3"> | |||||
| <div class="rounded-2xl bg-[var(--c-f8fbff)] p-4"> | |||||
| <div class="text-xs font-bold text-[var(--c-7b8797)]">成功邀请</div> | |||||
| <div class="mt-1 text-2xl font-extrabold tabular-nums text-[var(--c-172033)]"> | |||||
| {{ inviteTask.stats?.success || 0 }} | |||||
| </div> | |||||
| </div> | |||||
| <div class="rounded-2xl bg-[var(--c-f8fbff)] p-4"> | |||||
| <div class="text-xs font-bold text-[var(--c-7b8797)]">累计获得</div> | |||||
| <div class="mt-1 text-2xl font-extrabold tabular-nums text-[var(--c-0b8cff)]"> | |||||
| {{ inviteTask.stats?.inviter_points || 0 }} | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <div class="mt-auto pt-5"> | |||||
| <NuxtLink | |||||
| to="/invite" | |||||
| class="flex h-11 cursor-pointer items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)] transition-opacity hover:opacity-90" | |||||
| > | |||||
| <UIcon name="i-lucide-user-plus" class="size-4" /> | |||||
| 去邀请好友 | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </section> | |||||
| </div> | |||||
| <!-- 规则 --> | |||||
| <section v-if="ruleLines.length" class="mt-5 rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_8px_24px_var(--sh-34-68-112-45)] lg:p-6"> | |||||
| <h2 class="text-base font-extrabold text-[var(--c-172033)]">规则说明</h2> | |||||
| <ul class="mt-3 space-y-2"> | |||||
| <li v-for="(line, index) in ruleLines" :key="line" class="flex items-start gap-3 text-sm leading-relaxed text-[var(--c-5f6f83)]"> | |||||
| <span class="mt-0.5 flex h-5 w-7 shrink-0 items-center justify-center rounded-[14px] bg-[var(--c-eef7ff)] text-[11px] font-extrabold text-[var(--c-0b8cff)]"> | |||||
| {{ String(index + 1).padStart(2, '0') }} | |||||
| </span> | |||||
| {{ line }} | |||||
| </li> | |||||
| </ul> | |||||
| </section> | |||||
| </template> | |||||
| </template> | |||||
| </main> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| useSeoMeta({ | |||||
| title: '获得点数 - AI在线', | |||||
| description: '每日签到、邀请好友注册或扫码在小程序看视频,即可获得 AI 在线点数,点数多端共享,可用于全部 AI 工具。', | |||||
| }) | |||||
| const { post } = useApi() | |||||
| const { isLogin, showLogin, refreshBalance } = useUser() | |||||
| const { config, loading, entryEnabled, adTask, inviteTask, checkinTask, load } = useEarnPoints() | |||||
| const toast = useToast() | |||||
| const router = useRouter() | |||||
| const balance = ref(0) | |||||
| const qrcodeUrl = ref('') | |||||
| const qrLoading = ref(false) | |||||
| const checkinLoading = ref(false) | |||||
| const arrivedPoints = ref(0) | |||||
| // 扫码后点数在小程序侧到账,PC 只能轮询余额;最多轮 2 分钟,避免无意义的长轮询 | |||||
| const POLL_INTERVAL = 5000 | |||||
| const POLL_LIMIT = 2 * 60 * 1000 | |||||
| let pollTimer = null | |||||
| let pollStart = 0 | |||||
| const adProgressText = computed(() => { | |||||
| if (!adTask.value.enabled) return '' | |||||
| if (adTask.value.daily_limit > 0) { | |||||
| return `今日 ${adTask.value.today_used || 0}/${adTask.value.daily_limit} 次` | |||||
| } | |||||
| return '今日不限次数' | |||||
| }) | |||||
| const ruleLines = computed(() => { | |||||
| const lines = [] | |||||
| if (checkinTask.value.enabled && checkinTask.value.rule_text) lines.push(...splitRule(checkinTask.value.rule_text)) | |||||
| if (adTask.value.enabled && adTask.value.rule_text) lines.push(...splitRule(adTask.value.rule_text)) | |||||
| if (inviteTask.value.enabled && inviteTask.value.rule_text) lines.push(...splitRule(inviteTask.value.rule_text)) | |||||
| return lines | |||||
| }) | |||||
| function splitRule(text) { | |||||
| return pointCopy(text) | |||||
| .split(/[\n;;]/) | |||||
| .map(item => item.trim()) | |||||
| .filter(Boolean) | |||||
| } | |||||
| async function init() { | |||||
| const data = await load(true) | |||||
| balance.value = Number(data?.balance?.points || 0) | |||||
| // 所有免费任务都关闭时本页没有意义,直接送去充值页 | |||||
| if (!data?.entry_enabled) router.replace('/recharge') | |||||
| } | |||||
| // 兼容后台历史配置中仍使用“积分”的文案,前台统一展示为“点数”。 | |||||
| function pointCopy(text, fallback = '') { | |||||
| return String(text || fallback).replace(/积分/g, '点数') | |||||
| } | |||||
| onMounted(() => { | |||||
| if (isLogin.value) init() | |||||
| else load(true) | |||||
| }) | |||||
| onUnmounted(() => stopPoll()) | |||||
| watch(isLogin, (v) => { | |||||
| if (v) init() | |||||
| }) | |||||
| async function claimCheckin() { | |||||
| if (checkinLoading.value || checkinTask.value.checked_in) return | |||||
| checkinLoading.value = true | |||||
| try { | |||||
| const res = await post('/point/checkin', {}) | |||||
| const data = res.data || {} | |||||
| if (data.balance) balance.value = Number(data.balance.points || 0) | |||||
| arrivedPoints.value = Number(data.points || 0) | |||||
| toast.add({ | |||||
| title: data.duplicated ? '今天已经签到过了' : `签到成功,点数 +${data.points || checkinTask.value.points_per_checkin}`, | |||||
| color: data.duplicated ? 'neutral' : 'success', | |||||
| }) | |||||
| await refreshBalance() | |||||
| await load(true) | |||||
| } catch (e) { | |||||
| toast.add({ title: e.message || '签到失败,请稍后再试', color: 'error' }) | |||||
| } finally { | |||||
| checkinLoading.value = false | |||||
| } | |||||
| } | |||||
| async function loadQrcode() { | |||||
| if (qrLoading.value) return | |||||
| qrLoading.value = true | |||||
| try { | |||||
| const res = await post('/point/earnqrcode', {}) | |||||
| qrcodeUrl.value = res.data?.qrcode_url || '' | |||||
| if (!qrcodeUrl.value) throw new Error('小程序码生成失败') | |||||
| startPoll() | |||||
| } catch (e) { | |||||
| toast.add({ title: e.message || '小程序码生成失败', color: 'error' }) | |||||
| } finally { | |||||
| qrLoading.value = false | |||||
| } | |||||
| } | |||||
| /** 轮询余额:小程序侧看完广告后,这里能自动看到到账 */ | |||||
| function startPoll() { | |||||
| stopPoll() | |||||
| pollStart = Date.now() | |||||
| pollTimer = setInterval(async () => { | |||||
| if (Date.now() - pollStart > POLL_LIMIT) return stopPoll() | |||||
| try { | |||||
| const res = await post('/point/balance', {}) | |||||
| const points = Number(res.data?.points || 0) | |||||
| if (points > balance.value) { | |||||
| arrivedPoints.value = points - balance.value | |||||
| balance.value = points | |||||
| refreshBalance() | |||||
| load(true) | |||||
| } | |||||
| } catch (e) { | |||||
| // 单次失败不打断轮询 | |||||
| } | |||||
| }, POLL_INTERVAL) | |||||
| } | |||||
| function stopPoll() { | |||||
| if (pollTimer) clearInterval(pollTimer) | |||||
| pollTimer = null | |||||
| } | |||||
| </script> | |||||
| <style scoped> | |||||
| .tip-enter-active, | |||||
| .tip-leave-active { transition: opacity 0.2s ease; } | |||||
| .tip-enter-from, | |||||
| .tip-leave-to { opacity: 0; } | |||||
| @media (prefers-reduced-motion: reduce) { | |||||
| .tip-enter-active, | |||||
| .tip-leave-active { transition: none; } | |||||
| } | |||||
| </style> | |||||
| @@ -1,57 +1,461 @@ | |||||
| <template> | <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" | |||||
| <div class="overflow-hidden"> | |||||
| <!-- ==================== 1. Hero ==================== --> | |||||
| <section class="relative border-b border-[var(--c-e8eef7)] bg-gradient-to-br from-[var(--c-ffffff)] via-[var(--c-eef6ff)] to-[var(--c-f3fbf9)]"> | |||||
| <div class="pointer-events-none absolute -right-32 -top-40 size-[420px] rounded-full bg-[var(--c-0b8cff)]/10 blur-3xl" /> | |||||
| <div class="pointer-events-none absolute -bottom-40 -left-24 size-[360px] rounded-full bg-[var(--c-20d3a2)]/10 blur-3xl" /> | |||||
| <div class="relative mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8 lg:py-24"> | |||||
| <div class="grid items-center gap-12 lg:grid-cols-2 lg:gap-16"> | |||||
| <!-- 左:文案 --> | |||||
| <div> | |||||
| <span class="inline-flex items-center gap-1.5 rounded-full bg-[var(--c-ffffff)] px-3.5 py-1.5 text-xs font-extrabold text-[var(--c-0b8cff)] shadow-[0_4px_14px_var(--sh-11-140-255-120)]"> | |||||
| <UIcon name="i-lucide-sparkles" class="size-3.5" /> | |||||
| 一站式 AI 创作平台 | |||||
| </span> | |||||
| <h1 class="mt-5 text-4xl font-extrabold leading-[1.15] tracking-tight text-[var(--c-172033)] sm:text-5xl lg:text-[56px]"> | |||||
| 解放你的<br> | |||||
| <span class="bg-gradient-to-r from-[var(--c-0b8cff)] via-[var(--c-3aa9ff)] to-[var(--c-20d3a2)] bg-clip-text text-transparent">生产力</span> | |||||
| </h1> | |||||
| <p class="mt-5 max-w-xl text-base leading-relaxed text-[var(--c-66758a)] sm:text-lg"> | |||||
| AI 取名、动漫头像、证件照、AI 绘画……把重复又费时的创作交给 AI, | |||||
| 几秒钟拿到可直接用的结果。按点数计费,多端通用。 | |||||
| </p> | |||||
| <div class="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center"> | |||||
| <NuxtLink | |||||
| to="/writing/naming/" | |||||
| class="inline-flex h-13 items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] px-8 text-base font-bold text-white shadow-[0_14px_30px_var(--sh-11-140-255-280)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_36px_var(--sh-11-140-255-360)]" | |||||
| > | |||||
| <UIcon name="i-lucide-zap" class="size-5" /> | |||||
| 免费开始创作 | |||||
| </NuxtLink> | |||||
| <a | |||||
| href="#tools" | |||||
| class="inline-flex h-13 items-center justify-center gap-2 rounded-full bg-[var(--c-ffffff)] px-8 text-base font-bold text-[var(--c-172033)] shadow-[0_8px_20px_var(--sh-23-32-51-60)] transition-colors hover:bg-[var(--c-f7faff)]" | |||||
| > | |||||
| 查看全部工具 | |||||
| <UIcon name="i-lucide-arrow-down" class="size-4" /> | |||||
| </a> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 右:动漫头像示例(原图 / 生成图 对照) --> | |||||
| <div class="relative mx-auto w-full max-w-lg lg:max-w-none"> | |||||
| <div class="grid grid-cols-2 gap-4 sm:gap-5"> | |||||
| <!-- 原图 --> | |||||
| <figure class="group relative overflow-hidden rounded-3xl bg-[var(--c-e7eef8)] shadow-[0_18px_44px_var(--sh-23-58-110-120)]"> | |||||
| <img | |||||
| :src="demo.original" | |||||
| alt="上传的原始照片示例" | |||||
| class="aspect-[3/4] w-full object-cover" | |||||
| loading="eager" | |||||
| > | |||||
| <figcaption class="absolute left-3 top-3 rounded-full bg-[var(--c-0f172a)]/55 px-2.5 py-1 text-[11px] font-bold text-white backdrop-blur"> | |||||
| 原图 | |||||
| </figcaption> | |||||
| </figure> | |||||
| <!-- 生成图 --> | |||||
| <figure class="group relative overflow-hidden rounded-3xl bg-[var(--c-e7eef8)] shadow-[0_24px_54px_var(--sh-11-140-255-220)] ring-2 ring-[var(--c-0b8cff)] sm:translate-y-6"> | |||||
| <img | |||||
| :src="demo.anime" | |||||
| alt="AI 生成的动漫头像示例" | |||||
| class="aspect-[3/4] w-full object-cover" | |||||
| loading="eager" | |||||
| > | |||||
| <figcaption class="absolute left-3 top-3 rounded-full bg-[var(--c-0b8cff)] px-2.5 py-1 text-[11px] font-bold text-white"> | |||||
| AI 动漫头像 | |||||
| </figcaption> | |||||
| </figure> | |||||
| </div> | |||||
| <!-- 浮标 --> | |||||
| <div class="absolute -top-4 right-2 flex items-center gap-1.5 rounded-full bg-[var(--c-ffffff)] px-3.5 py-2 shadow-[0_10px_24px_var(--sh-23-58-110-160)]"> | |||||
| <UIcon name="i-lucide-sparkles" class="size-4 text-[var(--c-ff8a00)]" /> | |||||
| <span class="text-xs font-extrabold text-[var(--c-172033)]">10 秒出图</span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </section> | |||||
| <!-- ==================== 2. 工具(Services) ==================== --> | |||||
| <section id="tools" class="mx-auto max-w-7xl scroll-mt-20 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"> | |||||
| <div class="mx-auto max-w-2xl text-center"> | |||||
| <span class="inline-flex items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-3 py-1 text-xs font-extrabold text-[var(--c-0b8cff)]"> | |||||
| <UIcon name="i-lucide-layout-grid" class="size-3.5" /> | |||||
| AI 工具箱 | |||||
| </span> | |||||
| <h2 class="mt-4 text-3xl font-extrabold text-[var(--c-172033)] sm:text-4xl">开箱即用的 AI 能力</h2> | |||||
| <p class="mt-4 text-base leading-relaxed text-[var(--c-66758a)]"> | |||||
| 不用学提示词、不用配环境,选好参数点一下就出结果。 | |||||
| </p> | |||||
| </div> | |||||
| <div class="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> | |||||
| <template v-for="tool in tools" :key="tool.title"> | |||||
| <!-- 已上线:可点击卡片 --> | |||||
| <NuxtLink | |||||
| v-if="!tool.soon" | |||||
| :to="tool.to" | |||||
| class="group relative flex cursor-pointer flex-col overflow-hidden rounded-3xl border border-[var(--c-eef2f7)] bg-[var(--c-ffffff)] p-6 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--c-0b8cff)]/30 hover:shadow-[0_20px_44px_var(--sh-23-58-110-120)]" | |||||
| > | |||||
| <span | |||||
| v-if="tool.hot" | |||||
| class="absolute right-5 top-5 rounded-full bg-[var(--c-fff4ec)] px-2.5 py-1 text-[10px] font-extrabold text-[var(--c-ff681f)]" | |||||
| >热门</span> | |||||
| <div | |||||
| class="flex size-14 items-center justify-center rounded-2xl transition-transform duration-300 group-hover:scale-105" | |||||
| :class="tool.iconBg" | |||||
| > | > | ||||
| 开始创作 | |||||
| </NuxtLink> | |||||
| <UIcon :name="tool.icon" class="size-7" :class="tool.iconColor" /> | |||||
| </div> | |||||
| <h3 class="mt-5 text-lg font-extrabold text-[var(--c-172033)]">{{ tool.title }}</h3> | |||||
| <p class="mt-2 flex-1 text-sm leading-relaxed text-[var(--c-66758a)]">{{ tool.desc }}</p> | |||||
| <div class="mt-5 flex items-center justify-between border-t border-[var(--c-f1f5fa)] pt-4"> | |||||
| <span class="text-sm font-extrabold text-[var(--c-0b8cff)]">{{ toolPrice(tool) }}</span> | |||||
| <span class="inline-flex items-center gap-1 text-sm font-bold text-[var(--c-172033)] transition-colors group-hover:text-[var(--c-0b8cff)]"> | |||||
| 立即使用 | |||||
| <UIcon name="i-lucide-arrow-right" class="size-4 transition-transform group-hover:translate-x-0.5" /> | |||||
| </span> | |||||
| </div> | |||||
| </NuxtLink> | |||||
| <!-- 未上线:静态卡片 --> | |||||
| <div | |||||
| v-else | |||||
| class="relative flex cursor-default flex-col overflow-hidden rounded-3xl border border-[var(--c-eef2f7)] bg-[var(--c-ffffff)] p-6 opacity-70" | |||||
| > | |||||
| <span class="absolute right-5 top-5 rounded-full bg-[var(--c-f1f5fa)] px-2.5 py-1 text-[10px] font-bold text-[var(--c-8a97a8)]"> | |||||
| 即将上线 | |||||
| </span> | |||||
| <div class="flex size-14 items-center justify-center rounded-2xl" :class="tool.iconBg"> | |||||
| <UIcon :name="tool.icon" class="size-7" :class="tool.iconColor" /> | |||||
| </div> | |||||
| <h3 class="mt-5 text-lg font-extrabold text-[var(--c-172033)]">{{ tool.title }}</h3> | |||||
| <p class="mt-2 flex-1 text-sm leading-relaxed text-[var(--c-66758a)]">{{ tool.desc }}</p> | |||||
| <div class="mt-5 flex items-center justify-between border-t border-[var(--c-f1f5fa)] pt-4"> | |||||
| <span class="text-sm font-extrabold text-[var(--c-0b8cff)]">{{ tool.price }}</span> | |||||
| <span class="text-sm text-[var(--c-a0aabb)]">敬请期待</span> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| </div> | |||||
| </section> | |||||
| <!-- ==================== 4. 点数套餐(Pricing) ==================== --> | |||||
| <section id="pricing" class="mx-auto max-w-7xl scroll-mt-20 px-4 py-16 sm:px-6 lg:px-8 lg:py-24"> | |||||
| <div class="mx-auto max-w-2xl text-center"> | |||||
| <span class="inline-flex items-center gap-1.5 rounded-full bg-[var(--c-fff4ec)] px-3 py-1 text-xs font-extrabold text-[var(--c-ff681f)]"> | |||||
| <UIcon name="i-lucide-coins" class="size-3.5" /> | |||||
| 点数套餐 | |||||
| </span> | |||||
| <h2 class="mt-4 text-3xl font-extrabold text-[var(--c-172033)] sm:text-4xl">按需付费,没有订阅</h2> | |||||
| <p class="mt-4 text-base text-[var(--c-66758a)]">点数长期有效,小程序、H5、PC 端共享同一份余额。</p> | |||||
| </div> | |||||
| <!-- 加载中骨架 --> | |||||
| <div v-if="pkgLoading" class="mx-auto mt-12 grid max-w-4xl gap-6 sm:grid-cols-3"> | |||||
| <div v-for="i in 3" :key="i" class="h-[380px] animate-pulse rounded-3xl bg-[var(--c-eef2f7)]" /> | |||||
| </div> | |||||
| <div v-else-if="packages.length" class="mx-auto mt-12 grid max-w-5xl items-start gap-6 sm:grid-cols-3"> | |||||
| <div | |||||
| v-for="(pkg, idx) in packages" | |||||
| :key="pkg.id" | |||||
| class="relative rounded-3xl border-2 bg-[var(--c-ffffff)] p-7 transition-all duration-300" | |||||
| :class="isFeatured(pkg, idx) | |||||
| ? 'border-[var(--c-0b8cff)] shadow-[0_24px_50px_var(--sh-11-140-255-180)] sm:-mt-4 sm:pb-10' | |||||
| : 'border-[var(--c-eef2f7)] hover:border-[var(--c-0b8cff)]/30 hover:shadow-[0_16px_36px_var(--sh-23-58-110-100)]'" | |||||
| > | |||||
| <span | |||||
| v-if="isFeatured(pkg, idx)" | |||||
| class="absolute -top-3.5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] px-4 py-1.5 text-[11px] font-extrabold text-white shadow-[0_8px_18px_var(--sh-11-140-255-300)]" | |||||
| > | |||||
| {{ pkg.badge || '最受欢迎' }} | |||||
| </span> | |||||
| <div class="flex size-12 items-center justify-center rounded-2xl bg-[var(--c-eef7ff)]"> | |||||
| <UIcon :name="pkgIcon(idx)" class="size-6 text-[var(--c-0b8cff)]" /> | |||||
| </div> | |||||
| <h3 class="mt-4 text-lg font-extrabold text-[var(--c-172033)]">{{ pkg.title }}</h3> | |||||
| <p class="mt-1 text-xs text-[var(--c-8a97a8)]">{{ pkgSubtitle(pkg) }}</p> | |||||
| <div class="mt-5 flex items-end gap-1"> | |||||
| <span class="text-4xl font-extrabold text-[var(--c-172033)]">¥{{ pkg.price_yuan }}</span> | |||||
| </div> | |||||
| <div v-if="pkg.old_price_yuan" class="mt-1 text-xs text-[var(--c-a0aabb)] line-through">¥{{ pkg.old_price_yuan }}</div> | |||||
| <ul class="mt-6 space-y-2.5"> | |||||
| <li v-for="f in pkgFeatures(pkg)" :key="f" class="flex items-start gap-2 text-sm text-[var(--c-4b5b70)]"> | |||||
| <UIcon name="i-lucide-check" class="mt-0.5 size-4 shrink-0 text-[var(--c-17a65a)]" /> | |||||
| <span>{{ f }}</span> | |||||
| </li> | |||||
| </ul> | |||||
| <NuxtLink | |||||
| to="/recharge" | |||||
| class="mt-7 flex h-11 items-center justify-center rounded-full text-sm font-bold transition-all" | |||||
| :class="isFeatured(pkg, idx) | |||||
| ? 'bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] text-white shadow-[0_10px_22px_var(--sh-11-140-255-250)] hover:opacity-90' | |||||
| : 'bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] hover:bg-[var(--c-e0f0ff)]'" | |||||
| > | |||||
| 立即充值 | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </div> | |||||
| <p class="mt-8 text-center text-xs text-[var(--c-a0aabb)]">* 点数购买后长期有效,不支持退款</p> | |||||
| </section> | |||||
| <!-- ==================== 4.5 热门资讯(移植原 PC 首页) ==================== --> | |||||
| <section v-if="news.length" class="border-y border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] py-16 lg:py-24"> | |||||
| <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> | |||||
| <div class="flex flex-wrap items-end justify-between gap-4"> | |||||
| <div> | |||||
| <span class="inline-flex items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-3 py-1 text-xs font-extrabold text-[var(--c-0b8cff)]"> | |||||
| <UIcon name="i-lucide-newspaper" class="size-3.5" /> | |||||
| AI 资讯 | |||||
| </span> | |||||
| <h2 class="mt-4 text-3xl font-extrabold text-[var(--c-172033)] sm:text-4xl">热门资讯</h2> | |||||
| <p class="mt-3 text-base text-[var(--c-66758a)]">AI 前沿动态、使用教程与常见问题解答</p> | |||||
| </div> | |||||
| <NuxtLink | |||||
| to="/info" | |||||
| class="inline-flex items-center gap-1.5 text-sm font-bold text-[var(--c-0b8cff)] hover:underline" | |||||
| > | |||||
| 查看全部资讯 | |||||
| <UIcon name="i-lucide-arrow-right" class="size-4" /> | |||||
| </NuxtLink> | |||||
| </div> | |||||
| <!-- 栏目快捷入口 --> | |||||
| <div class="mt-6 flex flex-wrap gap-2"> | |||||
| <NuxtLink | |||||
| v-for="col in COLUMNS" | |||||
| :key="col.colId" | |||||
| :to="col.path" | |||||
| class="inline-flex items-center gap-1.5 rounded-full bg-[var(--c-f7f9fd)] px-4 py-2 text-sm font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]" | |||||
| > | |||||
| <UIcon :name="col.icon" class="size-4" /> | |||||
| {{ col.name }} | |||||
| </NuxtLink> | |||||
| </div> | |||||
| <!-- 头条大卡 + 列表 --> | |||||
| <div class="mt-8 grid gap-6 lg:grid-cols-12 lg:gap-8"> | |||||
| <!-- 头条:图片铺满 + 底部渐变压字 --> | |||||
| <article v-if="topNews" class="lg:col-span-5"> | |||||
| <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" | |||||
| :to="topNews.url" | |||||
| class="group relative block h-full min-h-[320px] overflow-hidden rounded-3xl bg-[var(--c-0f172a)] shadow-[0_14px_36px_var(--sh-23-58-110-140)]" | |||||
| > | > | ||||
| 查看套餐 | |||||
| <img | |||||
| v-if="topNews.cover" | |||||
| :src="topNews.cover" | |||||
| :alt="topNews.title" | |||||
| class="absolute inset-0 size-full object-cover transition-transform duration-700 group-hover:scale-105" | |||||
| loading="lazy" | |||||
| > | |||||
| <div class="absolute inset-0 bg-gradient-to-t from-[var(--c-0b1324)]/92 via-[var(--c-0b1324)]/45 to-transparent" /> | |||||
| <div class="absolute left-5 top-5"> | |||||
| <span class="rounded-full bg-[var(--c-ffffff)]/95 px-2.5 py-1 text-[11px] font-extrabold text-[var(--c-0b8cff)]"> | |||||
| {{ colName(topNews.col_id) }} | |||||
| </span> | |||||
| </div> | |||||
| <div class="relative flex h-full min-h-[320px] flex-col justify-end p-5 sm:p-6"> | |||||
| <h3 class="line-clamp-2 text-xl font-extrabold leading-snug text-white sm:text-2xl"> | |||||
| {{ topNews.title }} | |||||
| </h3> | |||||
| <p v-if="topNews.info" class="mt-2.5 line-clamp-2 text-sm leading-relaxed text-white/75"> | |||||
| {{ topNews.info }} | |||||
| </p> | |||||
| <div class="mt-4 flex items-center gap-4 text-xs text-white/60"> | |||||
| <span>{{ topNews.time }}</span> | |||||
| <span class="inline-flex items-center gap-1"> | |||||
| <UIcon name="i-lucide-eye" class="size-3.5" /> | |||||
| {{ formatViews(topNews.view_num) }} | |||||
| </span> | |||||
| <span class="ml-auto inline-flex items-center gap-1 font-bold text-white/85 transition-transform group-hover:translate-x-0.5"> | |||||
| 阅读全文 | |||||
| <UIcon name="i-lucide-arrow-right" class="size-3.5" /> | |||||
| </span> | |||||
| </div> | |||||
| </div> | |||||
| </NuxtLink> | </NuxtLink> | ||||
| </article> | |||||
| <!-- 列表:统一缩略图 + 序号,桌面两列 --> | |||||
| <div class="lg:col-span-7"> | |||||
| <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"> | |||||
| <article v-for="(item, idx) in restNews" :key="item.id"> | |||||
| <NuxtLink | |||||
| :to="item.url" | |||||
| class="group flex h-full gap-3.5 rounded-2xl border border-[var(--c-eef2f7)] bg-[var(--c-ffffff)] p-3 transition-all hover:-translate-y-0.5 hover:border-[var(--c-0b8cff)]/30 hover:shadow-[0_12px_28px_var(--sh-23-58-110-100)]" | |||||
| > | |||||
| <!-- 缩略图(无封面时用序号占位,保证排版整齐) --> | |||||
| <div class="relative size-[74px] shrink-0 overflow-hidden rounded-xl bg-gradient-to-br from-[var(--c-eef6ff)] to-[var(--c-f3fbf9)]"> | |||||
| <img | |||||
| v-if="item.cover" | |||||
| :src="item.cover" | |||||
| :alt="item.title" | |||||
| class="size-full object-cover transition-transform duration-500 group-hover:scale-110" | |||||
| loading="lazy" | |||||
| > | |||||
| <span | |||||
| v-else | |||||
| class="flex size-full items-center justify-center text-lg font-extrabold text-[var(--c-0b8cff)]/45" | |||||
| >{{ idx + 2 }}</span> | |||||
| </div> | |||||
| <div class="flex min-w-0 flex-1 flex-col"> | |||||
| <h3 class="line-clamp-2 text-sm font-bold leading-snug text-[var(--c-172033)] transition-colors group-hover:text-[var(--c-0b8cff)]"> | |||||
| {{ item.title }} | |||||
| </h3> | |||||
| <div class="mt-auto flex items-center gap-2.5 pt-2 text-[11px] text-[var(--c-a0aabb)]"> | |||||
| <span class="rounded bg-[var(--c-f7f9fd)] px-1.5 py-0.5 font-bold text-[var(--c-7b8797)]"> | |||||
| {{ colName(item.col_id) }} | |||||
| </span> | |||||
| <span>{{ item.time }}</span> | |||||
| <span class="inline-flex items-center gap-0.5"> | |||||
| <UIcon name="i-lucide-eye" class="size-3" /> | |||||
| {{ formatViews(item.view_num) }} | |||||
| </span> | |||||
| </div> | |||||
| </div> | |||||
| </NuxtLink> | |||||
| </article> | |||||
| </div> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </section> | </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> | |||||
| <!-- ==================== 5. AI 绘画作品(数据源:/art/getlist) ==================== --> | |||||
| <section class="border-y border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] py-16 lg:py-24"> | |||||
| <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> | |||||
| <div class="flex flex-wrap items-end justify-between gap-5"> | |||||
| <div> | |||||
| <span class="inline-flex items-center gap-1.5 rounded-full bg-[var(--c-f3f0ff)] px-3 py-1 text-xs font-extrabold text-[var(--c-7a5af8)]"> | |||||
| <UIcon name="i-lucide-images" class="size-3.5" /> | |||||
| 作品广场 | |||||
| </span> | |||||
| <h2 class="mt-4 text-3xl font-extrabold text-[var(--c-172033)] sm:text-4xl">看看大家用 AI 画出了什么</h2> | |||||
| <p class="mt-3 text-base text-[var(--c-66758a)]">来自 AI 在线用户的公开作品,鼠标悬停可查看生成提示词</p> | |||||
| </div> | |||||
| <NuxtLink | |||||
| to="/pic/" | |||||
| class="inline-flex h-11 items-center gap-1.5 rounded-full bg-[var(--c-f7f9fd)] px-6 text-sm font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]" | |||||
| > | |||||
| 浏览全部作品 | |||||
| <UIcon name="i-lucide-arrow-right" class="size-4" /> | |||||
| </NuxtLink> | |||||
| </div> | |||||
| <!-- | |||||
| 12 张作品的马赛克排布(各断点都能整行铺满,不留空格): | |||||
| 桌面 5 列:首图占 2×2 + 右侧 6 小图(2 行 × 3 个) + 第 3 行 5 小图 | |||||
| 平板 3 列:首图占 2×2 + 右侧 2 小图 + 后续 3 行 × 3 个 | |||||
| 移动 2 列:全部等大,6 行 | |||||
| --> | |||||
| <div v-if="artworks.length" class="mt-10 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5 lg:gap-4"> | |||||
| <NuxtLink | |||||
| v-for="(item, idx) in artworks" | |||||
| :key="`${item.chat_open_id}_${item.img_index}`" | |||||
| :to="`/pic/${item.chat_open_id}_${item.img_index || 0}.html`" | |||||
| class="group relative aspect-square overflow-hidden rounded-2xl bg-[var(--c-eef3f8)] ring-1 ring-[var(--c-eef2f7)] transition-all hover:ring-2 hover:ring-[var(--c-0b8cff)]/50 hover:shadow-[0_14px_32px_var(--sh-23-58-110-140)]" | |||||
| :class="idx === 0 ? 'sm:col-span-2 sm:row-span-2' : ''" | |||||
| > | |||||
| <img | |||||
| :src="item.img" | |||||
| :alt="item.prompts || 'AI 绘画作品'" | |||||
| class="size-full object-cover transition-transform duration-700 group-hover:scale-105" | |||||
| loading="lazy" | |||||
| > | |||||
| <!-- 常驻的类型角标 --> | |||||
| <span class="absolute left-3 top-3 rounded-full bg-[var(--c-ffffff)]/90 px-2 py-0.5 text-[10px] font-bold text-[var(--c-4b5b70)] backdrop-blur"> | |||||
| {{ artTypeName(item.ai_type) }} | |||||
| </span> | |||||
| <!-- hover 提示词浮层 --> | |||||
| <div class="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"> | |||||
| <div class="p-3 lg:p-4"> | |||||
| <p | |||||
| v-if="item.prompts" | |||||
| class="line-clamp-3 text-xs leading-relaxed text-white/95" | |||||
| :class="idx === 0 && 'lg:text-sm'" | |||||
| > | |||||
| {{ item.prompts }} | |||||
| </p> | |||||
| <p v-else class="text-xs text-white/80">AI 生成作品</p> | |||||
| <div class="mt-2 flex items-center gap-3 text-[10px] text-white/70"> | |||||
| <span v-if="item.resolution">{{ item.resolution }}</span> | |||||
| <span class="inline-flex items-center gap-1"> | |||||
| <UIcon name="i-lucide-heart" class="size-3" /> | |||||
| {{ item.like_num || 0 }} | |||||
| </span> | |||||
| <span class="ml-auto inline-flex items-center gap-1 font-bold text-white"> | |||||
| 查看 | |||||
| <UIcon name="i-lucide-arrow-up-right" class="size-3" /> | |||||
| </span> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </NuxtLink> | |||||
| </div> | |||||
| <!-- 无数据兜底 --> | |||||
| <div v-else class="mt-10 rounded-3xl bg-[var(--c-f7f9fd)] py-16 text-center"> | |||||
| <UIcon name="i-lucide-image-off" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" /> | |||||
| <div class="text-base font-extrabold text-[var(--c-172033)]">还没有公开作品</div> | |||||
| <div class="mt-2 text-sm text-[var(--c-8a95a6)]">成为第一个分享 AI 作品的人</div> | |||||
| </div> | |||||
| </div> | </div> | ||||
| </section> | |||||
| <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> | |||||
| <!-- ==================== 6. 结尾 CTA ==================== --> | |||||
| <section class="relative overflow-hidden bg-[var(--c-f3f7fb)] py-16 lg:py-24"> | |||||
| <div class="pointer-events-none absolute -right-20 top-0 size-72 rounded-full bg-[var(--c-0b8cff)]/10 blur-3xl" /> | |||||
| <div class="relative mx-auto max-w-3xl px-4 text-center sm:px-6"> | |||||
| <h2 class="text-3xl font-extrabold text-[var(--c-172033)] sm:text-4xl">现在就开始,几秒钟拿到结果</h2> | |||||
| <p class="mt-4 text-base text-[var(--c-66758a)]">注册即可体验,按点数付费,不用订阅、不绑定套餐。</p> | |||||
| <div class="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row"> | |||||
| <NuxtLink | |||||
| to="/writing/naming/" | |||||
| class="inline-flex h-13 w-full items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] px-8 text-base font-bold text-white shadow-[0_14px_30px_var(--sh-11-140-255-280)] transition-all hover:-translate-y-0.5 sm:w-auto" | |||||
| > | |||||
| <UIcon name="i-lucide-zap" class="size-5" /> | |||||
| 开始创作 | |||||
| </NuxtLink> | |||||
| <NuxtLink | |||||
| to="/recharge" | |||||
| class="inline-flex h-13 w-full items-center justify-center rounded-full bg-[var(--c-ffffff)] px-8 text-base font-bold text-[var(--c-0b8cff)] shadow-[0_8px_20px_var(--sh-23-32-51-60)] transition-colors hover:bg-[var(--c-f7faff)] sm:w-auto" | |||||
| > | |||||
| 查看套餐 | |||||
| </NuxtLink> | |||||
| </div> | |||||
| <div class="mt-10 flex flex-wrap items-center justify-center gap-x-8 gap-y-3 text-sm text-[var(--c-66758a)]"> | |||||
| <span v-for="p in promises" :key="p" class="inline-flex items-center gap-1.5"> | |||||
| <UIcon name="i-lucide-circle-check" class="size-4 text-[var(--c-17a65a)]" /> | |||||
| {{ p }} | |||||
| </span> | |||||
| </div> | |||||
| </div> | </div> | ||||
| </section> | </section> | ||||
| </div> | </div> | ||||
| @@ -59,47 +463,226 @@ | |||||
| <script setup> | <script setup> | ||||
| useSeoMeta({ | useSeoMeta({ | ||||
| title: '奇想宇宙 - AI 创作平台', | |||||
| description: 'AI 问答、AI 绘画、AI 音乐、AI 取名、动漫头像 —— 一站式 AI 创作平台', | |||||
| keywords: 'AI,人工智能,Midjourney,AI绘画,AI音乐,AI取名', | |||||
| title: 'AI在线网站-你的AI伙伴让创作更简单', | |||||
| description: 'AI在线是一个 AI 工具的集合地,可以找到各种各样的 AI 工具,满足你的不同需求。无论你是想翻译一段文字,还是想生成一篇文章,或者是想制作一张图片,AI在线都能为你提供帮助, AI 工具大全宝库!', | |||||
| keywords: 'AI,AI在线,AI伙伴,免费AI网站', | |||||
| }) | |||||
| useHead({ | |||||
| link: [{ rel: 'canonical', href: 'https://tool.aionline.cc/' }], | |||||
| }) | |||||
| import { COLUMNS, articleUrl, colName, formatTime } from '~/composables/useArticle' | |||||
| const { post } = useApi() | |||||
| const { isLogin } = useUser() | |||||
| // ==================== 首屏动漫头像示例(后台可配,带兜底) ==================== | |||||
| const demo = ref({ | |||||
| original: 'https://cdn.aionline.cc/ai/anime/original_1.png', | |||||
| anime: 'https://cdn.aionline.cc/ai/anime/anime_1.png', | |||||
| }) | }) | ||||
| async function loadDemo() { | |||||
| try { | |||||
| const res = await post('/common/getconfig', { keys: 'anime_demo_v2,anime_demo' }) | |||||
| const rows = res.list || [] | |||||
| const v2 = rows.find(i => i.key === 'anime_demo_v2') | |||||
| if (v2?.value) { | |||||
| const data = typeof v2.value === 'string' ? JSON.parse(v2.value) : v2.value | |||||
| const first = Array.isArray(data) ? data[0] : data | |||||
| if (first?.original && first?.anime) { | |||||
| demo.value = { original: first.original, anime: first.anime } | |||||
| return | |||||
| } | |||||
| } | |||||
| const old = rows.find(i => i.key === 'anime_demo') | |||||
| if (old?.value) { | |||||
| const pics = String(old.value).split(',').map(s => s.trim()).filter(Boolean) | |||||
| if (pics.length >= 2) demo.value = { original: pics[0], anime: pics[1] } | |||||
| } | |||||
| } catch { | |||||
| // 保留兜底图 | |||||
| } | |||||
| } | |||||
| // ==================== 静态内容 ==================== | |||||
| const tools = [ | const tools = [ | ||||
| { | { | ||||
| title: 'AI 取名', | title: 'AI 取名', | ||||
| desc: '智能取名,结合姓氏、性别、出生日期,从诗经楚辞中精选好名字', | |||||
| to: '/ai-name', | |||||
| icon: 'div', | |||||
| desc: '结合姓氏、性别、出生信息,从诗经楚辞典故中生成有出处的好名字,含寓意与音律解析。', | |||||
| to: '/writing/naming/', | |||||
| icon: 'i-lucide-signature', | |||||
| iconBg: 'bg-[var(--c-eef7ff)]', | |||||
| iconColor: 'text-[var(--c-0b8cff)]', | |||||
| appKey: 'ai-name', | |||||
| price: '点数计费', | |||||
| hot: true, | |||||
| }, | }, | ||||
| { | { | ||||
| title: 'AI 动漫头像', | |||||
| desc: '上传自拍,一键生成专属动漫风格头像,多种风格可选', | |||||
| to: '/anime-avatar', | |||||
| icon: 'div', | |||||
| title: '动漫头像', | |||||
| desc: '上传一张自拍,一键生成专属动漫风格头像,多种画风可选,支持原图对比。', | |||||
| to: '/draw/anime/', | |||||
| icon: 'i-lucide-user-round', | |||||
| iconBg: 'bg-[var(--c-f3f0ff)]', | |||||
| iconColor: 'text-[var(--c-7a5af8)]', | |||||
| appKey: 'anime-avatar', | |||||
| billingParams: { model: 'tencent_anime' }, | |||||
| price: '点数计费', | |||||
| hot: true, | |||||
| }, | }, | ||||
| { | { | ||||
| title: 'AI 绘画', | |||||
| desc: '使用 Midjourney 生成高质量 AI 绘画作品,激发无限创意', | |||||
| to: '#', | |||||
| icon: 'div', | |||||
| title: '证件照', | |||||
| desc: '一键生成标准证件照,自动换底色、智能裁剪,满足各类办证尺寸要求。', | |||||
| to: '/id-photo', | |||||
| icon: 'i-lucide-id-card', | |||||
| iconBg: 'bg-[var(--c-edfdf4)]', | |||||
| iconColor: 'text-[var(--c-17a65a)]', | |||||
| appKey: 'id-photo', | |||||
| price: '按张计费', | |||||
| soon: false, | |||||
| }, | }, | ||||
| { | { | ||||
| title: 'AI 音乐', | |||||
| desc: 'AI 生成原创音乐,多种风格随心选择', | |||||
| to: '#', | |||||
| icon: 'div', | |||||
| title: '形象照 / 写真', | |||||
| desc: '上传一张人像,生成商务形象照、日常职业照,或古风、港风等艺术写真,一次最多 4 张。', | |||||
| to: '/portrait-photo', | |||||
| icon: 'i-lucide-camera', | |||||
| iconBg: 'bg-[var(--c-fff4ec)]', | |||||
| iconColor: 'text-[var(--c-ff681f)]', | |||||
| appKey: 'portrait-photo', | |||||
| billingParams: { model: 'standard', count: 1 }, | |||||
| price: '按张计费', | |||||
| soon: false, | |||||
| }, | }, | ||||
| { | { | ||||
| title: 'AI 问答', | |||||
| desc: '智能问答助手,随时随地解答你的疑问', | |||||
| to: '#', | |||||
| icon: 'div', | |||||
| title: '老照片修复', | |||||
| desc: '黑白、泛黄、有划痕的老照片,一键修复破损并自然上色,支持前后对比。', | |||||
| to: '/old-photo', | |||||
| icon: 'i-lucide-wand-sparkles', | |||||
| iconBg: 'bg-[var(--c-fff4ec)]', | |||||
| iconColor: 'text-[var(--c-e85f1c)]', | |||||
| appKey: 'old-photo', | |||||
| billingParams: { mode: 'colorize' }, | |||||
| price: '按张计费', | |||||
| soon: false, | |||||
| }, | }, | ||||
| { | { | ||||
| title: '更多工具', | |||||
| desc: '文本转语音、AI 装修设计等更多 AI 能力持续上线中', | |||||
| title: '更多能力', | |||||
| desc: '文本转语音、AI 装修设计等更多 AI 能力持续上线,敬请期待。', | |||||
| to: '#', | to: '#', | ||||
| icon: 'div', | |||||
| icon: 'i-lucide-layout-grid', | |||||
| iconBg: 'bg-[var(--c-f1f5fa)]', | |||||
| iconColor: 'text-[var(--c-8a97a8)]', | |||||
| price: '持续更新', | |||||
| soon: true, | |||||
| }, | }, | ||||
| ] | ] | ||||
| const { data: toolBillingData } = await useAsyncData('home-tool-billing', async () => { | |||||
| const billableTools = tools.filter(tool => tool.appKey) | |||||
| const entries = await Promise.all(billableTools.map(async (tool) => { | |||||
| try { | |||||
| const res = await post('/billing/config', { | |||||
| app_key: tool.appKey, | |||||
| ...(tool.billingParams || {}), | |||||
| }) | |||||
| const rawPoints = res?.data?.quote?.points ?? res?.data?.points | |||||
| const points = Number(rawPoints) | |||||
| return [tool.appKey, Number.isFinite(points) ? points : null] | |||||
| } catch { | |||||
| return [tool.appKey, null] | |||||
| } | |||||
| })) | |||||
| return Object.fromEntries(entries) | |||||
| }) | |||||
| function toolPrice(tool) { | |||||
| const points = toolBillingData.value?.[tool.appKey] | |||||
| return points === 0 ? '免费使用' : tool.price | |||||
| } | |||||
| const promises = ['点数长期有效', '多端共享余额', '结果可下载复制', '生成失败不扣点'] | |||||
| // ==================== 点数套餐(真实数据) ==================== | |||||
| const packages = ref([]) | |||||
| const pkgLoading = ref(true) | |||||
| async function loadPackages() { | |||||
| try { | |||||
| const res = await post('/point/packagelist') | |||||
| packages.value = (res.list || []).slice(0, 3) | |||||
| } catch { | |||||
| packages.value = [] | |||||
| } finally { | |||||
| pkgLoading.value = false | |||||
| } | |||||
| } | |||||
| // 中间一档或带「推荐」角标的高亮 | |||||
| function isFeatured(pkg, idx) { | |||||
| const hasRecommend = packages.value.some(p => p.badge === '推荐') | |||||
| return hasRecommend ? pkg.badge === '推荐' : idx === 1 | |||||
| } | |||||
| function pkgIcon(idx) { | |||||
| return ['i-lucide-sprout', 'i-lucide-rocket', 'i-lucide-crown'][idx] || 'i-lucide-coins' | |||||
| } | |||||
| function pkgSubtitle(pkg) { | |||||
| const p = Number(pkg.points || 0) | |||||
| if (p <= 200) return '轻量体验,先试试手感' | |||||
| if (p <= 1500) return '日常创作,性价比最高' | |||||
| return '高频创作,一次买够' | |||||
| } | |||||
| function pkgFeatures(pkg) { | |||||
| return [ | |||||
| `获得 ${pkg.points} 点数`, | |||||
| '点数长期有效,不过期', | |||||
| '小程序 / H5 / PC 通用', | |||||
| '可用于全部 AI 工具', | |||||
| ] | |||||
| } | |||||
| // ==================== 热门资讯(移植原 PC 首页,SSR 渲染利于 SEO) ==================== | |||||
| const { data: newsData } = await useAsyncData('home-news', () => | |||||
| post('/article/getlist', { page_no: 1, page_size: 7, is_recommend: 1 }) | |||||
| .catch(() => ({ list: [] }))) | |||||
| const news = computed(() => | |||||
| (newsData.value?.list || []).map(i => ({ ...i, url: articleUrl(i), time: formatTime(i.publish_time, 'YYYY-MM-DD') }))) | |||||
| const topNews = computed(() => news.value[0] || null) | |||||
| const restNews = computed(() => news.value.slice(1, 7)) | |||||
| /** 阅读量简写:1280 → 1.3k */ | |||||
| function formatViews(n) { | |||||
| const v = Number(n || 0) | |||||
| return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v) | |||||
| } | |||||
| // ==================== AI 绘画作品(数据源同原 PC 首页:/art/getlist) ==================== | |||||
| // ai_type:2 MJ绘画 3/7/8 AI画头像 4 变年龄 5 变性别 6 AI绘画 | |||||
| const ART_TYPE_MAP = { | |||||
| 2: 'MJ 绘画', 3: 'AI 头像', 4: 'AI 变年龄', | |||||
| 5: 'AI 变性别', 6: 'AI 绘画', 7: 'AI 头像', 8: 'AI 头像', | |||||
| } | |||||
| function artTypeName(type) { | |||||
| return ART_TYPE_MAP[Number(type)] || 'AI 作品' | |||||
| } | |||||
| const { data: artData } = await useAsyncData('home-art', () => | |||||
| post('/art/getlist', { page_no: 1, page_size: 30 }).catch(() => ({ list: [] }))) | |||||
| // 展示 12 张:首图占 2×2,其余 1×1,桌面 5 列共 3 行正好铺满 | |||||
| const artworks = computed(() => (artData.value?.list || []).filter(i => i.img).slice(0, 12)) | |||||
| onMounted(() => { | |||||
| loadDemo() | |||||
| loadPackages() | |||||
| }) | |||||
| watch(isLogin, () => loadPackages()) | |||||
| </script> | </script> | ||||
| <style scoped> | |||||
| /* Tailwind 没有 h-13,这里补一个统一的按钮高度 */ | |||||
| .h-13 { | |||||
| height: 3.25rem; | |||||
| } | |||||
| </style> | |||||
| @@ -0,0 +1,14 @@ | |||||
| <template> | |||||
| <ArticleDetailView | |||||
| :detail="detail" | |||||
| :hot-list="data?.hotList || []" | |||||
| :tag-list="data?.tagList || []" | |||||
| /> | |||||
| </template> | |||||
| <script setup> | |||||
| import { useArticleDetail } from '~/composables/useArticle' | |||||
| // 使用教程详情:/info/course/{id}.html(与原 PC 站保持一致) | |||||
| const { data, detail } = await useArticleDetail(2) | |||||
| </script> | |||||
| @@ -0,0 +1,51 @@ | |||||
| <template> | |||||
| <ArticleListView | |||||
| :col-id="2" | |||||
| :heading="heading" | |||||
| subtitle="AI 工具使用教程与实操指南,从入门到进阶,手把手带你用好各类 AI 能力。" | |||||
| base-path="/info/course" | |||||
| :page="page" | |||||
| :page-size="PAGE_SIZE" | |||||
| :list="data?.list || []" | |||||
| :total="data?.total || 0" | |||||
| :hot-list="data?.hotList || []" | |||||
| :tag-list="data?.tagList || []" | |||||
| :pending="pending" | |||||
| /> | |||||
| </template> | |||||
| <script setup> | |||||
| import { useArticleData } from '~/composables/useArticle' | |||||
| import { | |||||
| LEGACY_DEFAULT_DESCRIPTION, | |||||
| LEGACY_DEFAULT_KEYWORDS, | |||||
| legacyPageTitle, | |||||
| } from '~/composables/useLegacySeo' | |||||
| const PAGE_SIZE = 10 | |||||
| const route = useRoute() | |||||
| const page = computed(() => Number(route.params.page || route.query.page || 1)) | |||||
| const { data, pending } = await useArticleData({ | |||||
| key: computed(() => `info-course-${page.value}`), | |||||
| colId: 2, | |||||
| page, | |||||
| pageSize: PAGE_SIZE, | |||||
| }) | |||||
| const heading = computed(() => (page.value > 1 ? `使用教程(第 ${page.value} 页)` : '使用教程')) | |||||
| const title = computed(() => | |||||
| legacyPageTitle(page.value > 1 ? `使用教程-第${page.value}页` : '使用教程')) | |||||
| useSeoMeta({ | |||||
| title, | |||||
| description: LEGACY_DEFAULT_DESCRIPTION, | |||||
| keywords: LEGACY_DEFAULT_KEYWORDS, | |||||
| ogTitle: title, | |||||
| ogType: 'website', | |||||
| }) | |||||
| useHead({ | |||||
| link: [{ rel: 'canonical', href: () => `https://tool.aionline.cc${page.value > 1 ? `/info/course${page.value}` : '/info/course'}` }], | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,48 @@ | |||||
| <template> | |||||
| <ArticleListView | |||||
| :heading="heading" | |||||
| subtitle="关注 AI 在线资讯频道,了解最新的 AI 前沿信息、研究进展、应用案例与行业动态。" | |||||
| base-path="/info" | |||||
| :page="page" | |||||
| :page-size="PAGE_SIZE" | |||||
| :list="data?.list || []" | |||||
| :total="data?.total || 0" | |||||
| :hot-list="data?.hotList || []" | |||||
| :tag-list="data?.tagList || []" | |||||
| :pending="pending" | |||||
| /> | |||||
| </template> | |||||
| <script setup> | |||||
| import { useArticleData } from '~/composables/useArticle' | |||||
| import { legacyPageTitle } from '~/composables/useLegacySeo' | |||||
| const PAGE_SIZE = 10 | |||||
| const route = useRoute() | |||||
| // 分页:支持 /info2(原站形式)与 /info?page=2 | |||||
| const page = computed(() => Number(route.params.page || route.query.page || 1)) | |||||
| const { data, pending } = await useArticleData({ | |||||
| key: computed(() => `info-all-${page.value}`), | |||||
| page, | |||||
| pageSize: PAGE_SIZE, | |||||
| }) | |||||
| const heading = computed(() => (page.value > 1 ? `AI 最新热点资讯(第 ${page.value} 页)` : 'AI 最新热点资讯')) | |||||
| // ==================== SEO(沿用原站文案) ==================== | |||||
| const title = computed(() => | |||||
| legacyPageTitle(page.value > 1 ? `AI最新热点新闻资讯-第${page.value}页` : 'AI最新热点新闻资讯')) | |||||
| useSeoMeta({ | |||||
| title, | |||||
| description: '关注AI在线资讯频道,了解最新的AI前沿信息,最新的AI研究进展、应用案例和行业动态', | |||||
| keywords: 'AI最新新闻、AI最新资讯、AI热点新闻', | |||||
| ogTitle: title, | |||||
| ogType: 'website', | |||||
| }) | |||||
| useHead({ | |||||
| link: [{ rel: 'canonical', href: () => `https://tool.aionline.cc${page.value > 1 ? `/info${page.value}` : '/info'}` }], | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,14 @@ | |||||
| <template> | |||||
| <ArticleDetailView | |||||
| :detail="detail" | |||||
| :hot-list="data?.hotList || []" | |||||
| :tag-list="data?.tagList || []" | |||||
| /> | |||||
| </template> | |||||
| <script setup> | |||||
| import { useArticleDetail } from '~/composables/useArticle' | |||||
| // 前沿资讯详情:/info/new/{id}.html(与原 PC 站保持一致) | |||||
| const { data, detail } = await useArticleDetail(1) | |||||
| </script> | |||||
| @@ -0,0 +1,51 @@ | |||||
| <template> | |||||
| <ArticleListView | |||||
| :col-id="1" | |||||
| :heading="heading" | |||||
| subtitle="AI 行业新闻报道与前沿动态,第一时间了解人工智能领域发生了什么。" | |||||
| base-path="/info/new" | |||||
| :page="page" | |||||
| :page-size="PAGE_SIZE" | |||||
| :list="data?.list || []" | |||||
| :total="data?.total || 0" | |||||
| :hot-list="data?.hotList || []" | |||||
| :tag-list="data?.tagList || []" | |||||
| :pending="pending" | |||||
| /> | |||||
| </template> | |||||
| <script setup> | |||||
| import { useArticleData } from '~/composables/useArticle' | |||||
| import { | |||||
| LEGACY_DEFAULT_DESCRIPTION, | |||||
| LEGACY_DEFAULT_KEYWORDS, | |||||
| legacyPageTitle, | |||||
| } from '~/composables/useLegacySeo' | |||||
| const PAGE_SIZE = 10 | |||||
| const route = useRoute() | |||||
| const page = computed(() => Number(route.params.page || route.query.page || 1)) | |||||
| const { data, pending } = await useArticleData({ | |||||
| key: computed(() => `info-new-${page.value}`), | |||||
| colId: 1, | |||||
| page, | |||||
| pageSize: PAGE_SIZE, | |||||
| }) | |||||
| const heading = computed(() => (page.value > 1 ? `前沿资讯(第 ${page.value} 页)` : '前沿资讯')) | |||||
| const title = computed(() => | |||||
| legacyPageTitle(page.value > 1 ? `AI新闻报道-第${page.value}页` : 'AI新闻报道')) | |||||
| useSeoMeta({ | |||||
| title, | |||||
| description: LEGACY_DEFAULT_DESCRIPTION, | |||||
| keywords: LEGACY_DEFAULT_KEYWORDS, | |||||
| ogTitle: title, | |||||
| ogType: 'website', | |||||
| }) | |||||
| useHead({ | |||||
| link: [{ rel: 'canonical', href: () => `https://tool.aionline.cc${page.value > 1 ? `/info/new${page.value}` : '/info/new'}` }], | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,67 @@ | |||||
| <template> | |||||
| <ArticleListView | |||||
| :tag-key="tagKey" | |||||
| :heading="heading" | |||||
| :subtitle="`与「${data?.tagName || tagKey}」相关的 AI 资讯、教程与问答。`" | |||||
| base-path="/info" | |||||
| :page="page" | |||||
| :page-size="PAGE_SIZE" | |||||
| :list="data?.list || []" | |||||
| :total="data?.total || 0" | |||||
| :tag-name="data?.tagName || ''" | |||||
| :hot-list="data?.hotList || []" | |||||
| :tag-list="data?.tagList || []" | |||||
| :pending="pending" | |||||
| /> | |||||
| </template> | |||||
| <script setup> | |||||
| import { useArticleData } from '~/composables/useArticle' | |||||
| import { | |||||
| LEGACY_DEFAULT_DESCRIPTION, | |||||
| LEGACY_DEFAULT_KEYWORDS, | |||||
| legacyPageTitle, | |||||
| } from '~/composables/useLegacySeo' | |||||
| const PAGE_SIZE = 10 | |||||
| const route = useRoute() | |||||
| /** | |||||
| * 原站标签分页 URL 形式:/info/tag/{key}.html 与 | |||||
| * /info/tag/{key}-{page}.html,这里先去后缀再拆 key 与页码。 | |||||
| */ | |||||
| const rawKey = computed(() => String(route.params.key || '').replace(/\.html$/i, '')) | |||||
| const parsed = computed(() => { | |||||
| const m = rawKey.value.match(/^(.+)-(\d+)$/) | |||||
| return m ? { key: m[1], page: Number(m[2]) } : { key: rawKey.value, page: 1 } | |||||
| }) | |||||
| const tagKey = computed(() => parsed.value.key) | |||||
| const page = computed(() => Number(route.query.page || parsed.value.page)) | |||||
| const { data, pending } = await useArticleData({ | |||||
| key: computed(() => `info-tag-${tagKey.value}-${page.value}`), | |||||
| tagKey, | |||||
| page, | |||||
| pageSize: PAGE_SIZE, | |||||
| }) | |||||
| const tagName = computed(() => data.value?.tagName || tagKey.value) | |||||
| const heading = computed(() => (page.value > 1 ? `${tagName.value}(第 ${page.value} 页)` : tagName.value)) | |||||
| const title = computed(() => | |||||
| legacyPageTitle(page.value > 1 ? `${tagName.value}-第${page.value}页` : tagName.value)) | |||||
| useSeoMeta({ | |||||
| title, | |||||
| description: LEGACY_DEFAULT_DESCRIPTION, | |||||
| keywords: LEGACY_DEFAULT_KEYWORDS, | |||||
| ogTitle: title, | |||||
| ogType: 'website', | |||||
| }) | |||||
| useHead({ | |||||
| link: [{ | |||||
| rel: 'canonical', | |||||
| href: () => `https://tool.aionline.cc/info/tag/${tagKey.value}${page.value > 1 ? `-${page.value}` : ''}.html`, | |||||
| }], | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,14 @@ | |||||
| <template> | |||||
| <ArticleDetailView | |||||
| :detail="detail" | |||||
| :hot-list="data?.hotList || []" | |||||
| :tag-list="data?.tagList || []" | |||||
| /> | |||||
| </template> | |||||
| <script setup> | |||||
| import { useArticleDetail } from '~/composables/useArticle' | |||||
| // AI 问答详情:/info/what/{id}.html(与原 PC 站保持一致) | |||||
| const { data, detail } = await useArticleDetail(3) | |||||
| </script> | |||||
| @@ -0,0 +1,51 @@ | |||||
| <template> | |||||
| <ArticleListView | |||||
| :col-id="3" | |||||
| :heading="heading" | |||||
| subtitle="AI 相关问题解答,帮你弄清人工智能的常见概念、原理与使用疑问。" | |||||
| base-path="/info/what" | |||||
| :page="page" | |||||
| :page-size="PAGE_SIZE" | |||||
| :list="data?.list || []" | |||||
| :total="data?.total || 0" | |||||
| :hot-list="data?.hotList || []" | |||||
| :tag-list="data?.tagList || []" | |||||
| :pending="pending" | |||||
| /> | |||||
| </template> | |||||
| <script setup> | |||||
| import { useArticleData } from '~/composables/useArticle' | |||||
| import { | |||||
| LEGACY_DEFAULT_DESCRIPTION, | |||||
| LEGACY_DEFAULT_KEYWORDS, | |||||
| legacyPageTitle, | |||||
| } from '~/composables/useLegacySeo' | |||||
| const PAGE_SIZE = 10 | |||||
| const route = useRoute() | |||||
| const page = computed(() => Number(route.params.page || route.query.page || 1)) | |||||
| const { data, pending } = await useArticleData({ | |||||
| key: computed(() => `info-what-${page.value}`), | |||||
| colId: 3, | |||||
| page, | |||||
| pageSize: PAGE_SIZE, | |||||
| }) | |||||
| const heading = computed(() => (page.value > 1 ? `AI 问答(第 ${page.value} 页)` : 'AI 问答')) | |||||
| const title = computed(() => | |||||
| legacyPageTitle(page.value > 1 ? `AI问答-第${page.value}页` : 'AI问答')) | |||||
| useSeoMeta({ | |||||
| title, | |||||
| description: LEGACY_DEFAULT_DESCRIPTION, | |||||
| keywords: LEGACY_DEFAULT_KEYWORDS, | |||||
| ogTitle: title, | |||||
| ogType: 'website', | |||||
| }) | |||||
| useHead({ | |||||
| link: [{ rel: 'canonical', href: () => `https://tool.aionline.cc${page.value > 1 ? `/info/what${page.value}` : '/info/what'}` }], | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,367 @@ | |||||
| <template> | |||||
| <div> | |||||
| <AppHero | |||||
| kicker="INVITE FRIENDS" | |||||
| title="邀请好友,一起解锁 AI 创作" | |||||
| desc="把好用的 AI 工具分享给朋友,好友完成新用户注册后,双方都能获得点数奖励。" | |||||
| > | |||||
| <template #aside> | |||||
| <div class="w-full overflow-hidden rounded-3xl bg-gradient-to-br from-[var(--c-0758c9)] via-[var(--c-0b8cff)] to-[var(--c-54c8ff)] p-5 text-white shadow-[0_20px_42px_var(--sh-11-126-225-240)] lg:w-[350px] lg:p-6"> | |||||
| <div class="flex items-center gap-2 text-xs font-bold text-white/75"> | |||||
| <UIcon name="i-lucide-gift" class="size-4" /> | |||||
| 双方奖励 | |||||
| </div> | |||||
| <div class="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-4 text-center"> | |||||
| <div> | |||||
| <div class="text-4xl font-extrabold tabular-nums">{{ rewardDisplay(config.inviter_points) }}</div> | |||||
| <div class="mt-2 text-xs text-white/70">你获得点数</div> | |||||
| </div> | |||||
| <div class="h-12 w-px bg-white/20" /> | |||||
| <div> | |||||
| <div class="text-4xl font-extrabold tabular-nums">{{ rewardDisplay(config.invitee_points) }}</div> | |||||
| <div class="mt-2 text-xs text-white/70">好友获得点数</div> | |||||
| </div> | |||||
| </div> | |||||
| <div class="mt-5 flex items-center justify-center gap-1.5 border-t border-white/15 pt-4 text-[11px] font-medium text-white/70"> | |||||
| <UIcon name="i-lucide-shield-check" class="size-3.5" /> | |||||
| 好友注册成功后自动发放 | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| </AppHero> | |||||
| <main class="mx-auto max-w-7xl px-4 py-7 sm:px-6 lg:px-8 lg:py-10"> | |||||
| <section | |||||
| v-if="!isLogin" | |||||
| class="rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] px-6 py-14 text-center shadow-[0_16px_40px_var(--sh-34-68-112-60)]" | |||||
| aria-labelledby="invite-login-title" | |||||
| > | |||||
| <div class="mx-auto flex size-16 items-center justify-center rounded-2xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-8 ring-[var(--c-f7fbff)]"> | |||||
| <UIcon name="i-lucide-user-plus" class="size-7" /> | |||||
| </div> | |||||
| <h2 id="invite-login-title" class="mt-6 text-xl font-extrabold text-[var(--c-172033)]">登录后生成专属邀请</h2> | |||||
| <p class="mx-auto mt-2 max-w-md text-sm leading-6 text-[var(--c-66758a)]">登录后即可复制专属邀请链接、生成微信小程序码并查看邀请奖励。</p> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-7 inline-flex h-11 cursor-pointer items-center gap-2 rounded-full bg-[var(--c-0b8cff)] px-7 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-240)] transition-colors hover:bg-[var(--c-087dde)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2" | |||||
| @click="showLogin = true" | |||||
| > | |||||
| <UIcon name="i-lucide-log-in" class="size-4" /> | |||||
| 立即登录 | |||||
| </button> | |||||
| </section> | |||||
| <template v-else> | |||||
| <div v-if="loading && !loaded" class="grid gap-5 lg:grid-cols-12" role="status" aria-label="正在加载邀请信息"> | |||||
| <div class="h-[330px] animate-pulse rounded-3xl bg-[var(--c-ffffff)] motion-reduce:animate-none lg:col-span-8" /> | |||||
| <div class="h-[330px] animate-pulse rounded-3xl bg-[var(--c-ffffff)] motion-reduce:animate-none lg:col-span-4" /> | |||||
| </div> | |||||
| <section | |||||
| v-else-if="loadError" | |||||
| class="rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] px-6 py-16 text-center shadow-[0_16px_40px_var(--sh-34-68-112-60)]" | |||||
| role="alert" | |||||
| > | |||||
| <UIcon name="i-lucide-circle-alert" class="mx-auto size-10 text-[var(--c-e85f1c)]" /> | |||||
| <h2 class="mt-4 text-lg font-extrabold text-[var(--c-172033)]">邀请信息加载失败</h2> | |||||
| <p class="mt-2 text-sm text-[var(--c-7b8797)]">{{ loadError }}</p> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-6 cursor-pointer rounded-full bg-[var(--c-eef7ff)] px-6 py-2.5 text-sm font-bold text-[var(--c-0b8cff)]" | |||||
| @click="loadData" | |||||
| > | |||||
| 重新加载 | |||||
| </button> | |||||
| </section> | |||||
| <div v-else class="grid gap-5 lg:grid-cols-12 lg:items-start"> | |||||
| <div class="space-y-5 lg:col-span-8"> | |||||
| <section class="overflow-hidden rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] shadow-[0_16px_40px_var(--sh-34-68-112-60)]" aria-labelledby="share-title"> | |||||
| <div class="border-b border-[var(--c-edf2f7)] px-5 py-5 sm:px-6"> | |||||
| <div class="flex flex-wrap items-center justify-between gap-3"> | |||||
| <div> | |||||
| <h2 id="share-title" class="text-lg font-extrabold text-[var(--c-172033)]">分享专属邀请</h2> | |||||
| <p class="mt-1 text-xs text-[var(--c-8a97a8)]">每位好友仅能绑定一位邀请人,奖励以注册时的活动配置为准。</p> | |||||
| </div> | |||||
| <span | |||||
| class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-bold" | |||||
| :class="activityEnabled ? 'bg-[var(--c-edfdf4)] text-[var(--c-159a54)]' : 'bg-[var(--c-f1f5fa)] text-[var(--c-7b8797)]'" | |||||
| > | |||||
| <span class="size-1.5 rounded-full" :class="activityEnabled ? 'bg-[var(--c-17a65a)]' : 'bg-[var(--c-9aa5b5)]'" /> | |||||
| {{ activityEnabled ? '活动进行中' : '活动暂未开放' }} | |||||
| </span> | |||||
| </div> | |||||
| </div> | |||||
| <div class="p-5 sm:p-6"> | |||||
| <div class="rounded-2xl border border-[var(--c-e6edf5)] bg-[var(--c-f8fbff)] p-3.5"> | |||||
| <div class="text-[11px] font-bold text-[var(--c-8a97a8)]">你的邀请链接</div> | |||||
| <div class="mt-2 flex min-w-0 items-center gap-3"> | |||||
| <div class="min-w-0 flex-1 truncate text-sm font-medium text-[var(--c-4b5b70)]">{{ inviteUrl }}</div> | |||||
| <button | |||||
| type="button" | |||||
| :disabled="!activityEnabled" | |||||
| class="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-xl bg-[var(--c-ffffff)] text-[var(--c-0b8cff)] shadow-sm transition-colors hover:bg-[var(--c-eef7ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] disabled:cursor-not-allowed disabled:text-[var(--c-a0aabb)]" | |||||
| aria-label="复制邀请链接" | |||||
| @click="copyLink" | |||||
| > | |||||
| <UIcon name="i-lucide-copy" class="size-4" /> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <div class="mt-4 grid gap-3 sm:grid-cols-2"> | |||||
| <button | |||||
| type="button" | |||||
| :disabled="!activityEnabled" | |||||
| class="flex h-12 cursor-pointer items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)] transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-45" | |||||
| @click="shareLink" | |||||
| > | |||||
| <UIcon name="i-lucide-share-2" class="size-4" /> | |||||
| 分享邀请链接 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| :disabled="!activityEnabled" | |||||
| class="flex h-12 cursor-pointer items-center justify-center gap-2 rounded-full border border-[var(--c-dbe9f7)] bg-[var(--c-f5faff)] text-sm font-extrabold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e8f5ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-45" | |||||
| @click="openQr" | |||||
| > | |||||
| <UIcon name="i-lucide-qr-code" class="size-4" /> | |||||
| 微信小程序码 | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| </section> | |||||
| <section class="grid gap-3 sm:grid-cols-2" aria-label="邀请统计"> | |||||
| <article class="rounded-2xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_8px_24px_var(--sh-34-68-112-45)]"> | |||||
| <div class="flex items-start justify-between"> | |||||
| <div> | |||||
| <p class="text-xs font-bold text-[var(--c-7b8797)]">成功邀请</p> | |||||
| <p class="mt-2 text-3xl font-extrabold text-[var(--c-172033)] tabular-nums">{{ stats.success || 0 }}</p> | |||||
| <p class="mt-1 text-[11px] text-[var(--c-9aa5b5)]">已完成注册并发放奖励</p> | |||||
| </div> | |||||
| <span class="flex size-10 items-center justify-center rounded-xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]"> | |||||
| <UIcon name="i-lucide-users" class="size-4.5" /> | |||||
| </span> | |||||
| </div> | |||||
| </article> | |||||
| <article class="rounded-2xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_8px_24px_var(--sh-34-68-112-45)]"> | |||||
| <div class="flex items-start justify-between"> | |||||
| <div> | |||||
| <p class="text-xs font-bold text-[var(--c-7b8797)]">累计奖励</p> | |||||
| <p class="mt-2 text-3xl font-extrabold text-[var(--c-159a54)] tabular-nums">+{{ stats.inviter_points || 0 }}</p> | |||||
| <p class="mt-1 text-[11px] text-[var(--c-9aa5b5)]">已自动计入可用点数</p> | |||||
| </div> | |||||
| <span class="flex size-10 items-center justify-center rounded-xl bg-[var(--c-edfdf4)] text-[var(--c-159a54)]"> | |||||
| <UIcon name="i-lucide-coins" class="size-4.5" /> | |||||
| </span> | |||||
| </div> | |||||
| </article> | |||||
| </section> | |||||
| <section class="rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_12px_30px_var(--sh-34-68-112-50)] sm:p-6" aria-labelledby="steps-title"> | |||||
| <h2 id="steps-title" class="text-lg font-extrabold text-[var(--c-172033)]">三步获得奖励</h2> | |||||
| <div class="mt-5 grid gap-3 sm:grid-cols-3"> | |||||
| <div v-for="step in steps" :key="step.no" class="rounded-2xl bg-[var(--c-f6f9fd)] p-4"> | |||||
| <div class="flex items-center justify-between"> | |||||
| <span class="text-[11px] font-extrabold tracking-[0.12em] text-[var(--c-0b8cff)]">STEP {{ step.no }}</span> | |||||
| <UIcon :name="step.icon" class="size-4 text-[var(--c-8a97a8)]" /> | |||||
| </div> | |||||
| <h3 class="mt-4 text-sm font-extrabold text-[var(--c-172033)]">{{ step.title }}</h3> | |||||
| <p class="mt-1.5 text-xs leading-5 text-[var(--c-7b8797)]">{{ step.desc }}</p> | |||||
| </div> | |||||
| </div> | |||||
| </section> | |||||
| </div> | |||||
| <aside class="space-y-5 lg:col-span-4"> | |||||
| <section class="rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] p-5 shadow-[0_12px_30px_var(--sh-34-68-112-50)] sm:p-6"> | |||||
| <div class="flex items-center justify-between gap-3"> | |||||
| <h2 class="text-base font-extrabold text-[var(--c-172033)]">最近邀请</h2> | |||||
| <NuxtLink to="/invite/records" class="inline-flex items-center gap-1 text-xs font-bold text-[var(--c-0b8cff)] hover:underline"> | |||||
| 全部记录 | |||||
| <UIcon name="i-lucide-chevron-right" class="size-3.5" /> | |||||
| </NuxtLink> | |||||
| </div> | |||||
| <div v-if="recentList.length" class="mt-4 divide-y divide-[var(--c-edf2f7)]"> | |||||
| <div v-for="item in recentList" :key="item.id" class="flex items-center gap-3 py-3 first:pt-0 last:pb-0"> | |||||
| <div class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--c-eef7ff)] text-sm font-extrabold text-[var(--c-0b8cff)]"> | |||||
| <img v-if="item.avatar" :src="item.avatar" :alt="item.user_name || '好友'" class="size-full object-cover"> | |||||
| <span v-else>{{ getInitial(item.user_name) }}</span> | |||||
| </div> | |||||
| <div class="min-w-0 flex-1"> | |||||
| <div class="truncate text-sm font-bold text-[var(--c-172033)]">{{ item.user_name || '好友' }}</div> | |||||
| <div class="mt-0.5 text-[11px] text-[var(--c-9aa5b5)]">{{ formatTime(item.reward_time || item.add_time) }}</div> | |||||
| </div> | |||||
| <span class="shrink-0 text-xs font-extrabold" :class="Number(item.status) === 1 ? 'text-[var(--c-159a54)]' : 'text-[var(--c-8a97a8)]'"> | |||||
| {{ Number(item.status) === 1 ? `+${item.inviter_points || 0}` : item.status_text }} | |||||
| </span> | |||||
| </div> | |||||
| </div> | |||||
| <div v-else class="py-9 text-center"> | |||||
| <UIcon name="i-lucide-user-round-plus" class="mx-auto size-8 text-[var(--c-b1bbc8)]" /> | |||||
| <p class="mt-3 text-sm font-bold text-[var(--c-66758a)]">还没有邀请记录</p> | |||||
| <p class="mt-1 text-xs text-[var(--c-9aa5b5)]">分享给第一位好友吧</p> | |||||
| </div> | |||||
| </section> | |||||
| <section class="rounded-3xl bg-gradient-to-br from-[var(--c-f0f8ff)] to-[var(--c-f7fbff)] p-5 ring-1 ring-inset ring-[var(--c-dcecfb)] sm:p-6"> | |||||
| <div class="flex items-center gap-2 text-sm font-extrabold text-[var(--c-172033)]"> | |||||
| <UIcon name="i-lucide-scroll-text" class="size-4 text-[var(--c-0b8cff)]" /> | |||||
| 活动规则 | |||||
| </div> | |||||
| <p class="mt-3 text-xs leading-6 text-[var(--c-66758a)]">{{ config.rule_text || '好友通过你的邀请完成新用户注册后,你和好友都可获得点数奖励。' }}</p> | |||||
| <div class="mt-4 border-t border-[var(--c-dcecfb)] pt-4 text-[11px] leading-5 text-[var(--c-8a97a8)]"> | |||||
| 每位新用户仅可触发一次邀请奖励;异常注册或超过每日奖励上限时,本次邀请可能只记录不发放。 | |||||
| </div> | |||||
| </section> | |||||
| </aside> | |||||
| </div> | |||||
| </template> | |||||
| </main> | |||||
| <InviteShareModal | |||||
| :open="qrOpen" | |||||
| :qrcode-url="qrcodeUrl" | |||||
| :invite-url="inviteUrl" | |||||
| :loading="qrLoading" | |||||
| @close="qrOpen = false" | |||||
| @copy="copyLink" | |||||
| /> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| import dayjs from 'dayjs' | |||||
| useSeoMeta({ | |||||
| title: '邀请好友 - AI在线', | |||||
| description: '邀请好友体验 AI 在线,双方领取 AI 创作点数。', | |||||
| robots: 'noindex, nofollow', | |||||
| }) | |||||
| const { userInfo, isLogin, showLogin } = useUser() | |||||
| const { post } = useApi() | |||||
| const { buildInviteUrl } = useInvite() | |||||
| const toast = useToast() | |||||
| const config = ref({}) | |||||
| const stats = ref({}) | |||||
| const recentList = ref([]) | |||||
| const inviteCode = ref('') | |||||
| const loading = ref(false) | |||||
| const loaded = ref(false) | |||||
| const loadError = ref('') | |||||
| const qrOpen = ref(false) | |||||
| const qrLoading = ref(false) | |||||
| const qrcodeUrl = ref('') | |||||
| const steps = [ | |||||
| { no: '01', title: '分享专属邀请', desc: '复制网页链接,或让好友扫描微信小程序码。', icon: 'i-lucide-share-2' }, | |||||
| { no: '02', title: '好友完成注册', desc: '好友通过邀请落地页登录并完成新用户注册。', icon: 'i-lucide-user-check' }, | |||||
| { no: '03', title: '点数自动到账', desc: '系统核验成功后,双方奖励直接计入点数余额。', icon: 'i-lucide-coins' }, | |||||
| ] | |||||
| const activityEnabled = computed(() => Number(config.value.status ?? 1) === 1) | |||||
| const inviteUrl = computed(() => buildInviteUrl(inviteCode.value || userInfo.value.open_id)) | |||||
| function rewardDisplay(value) { | |||||
| if (!loaded.value) return '—' | |||||
| return Number(value || 0) | |||||
| } | |||||
| async function loadData() { | |||||
| if (!isLogin.value || loading.value) return | |||||
| loading.value = true | |||||
| loadError.value = '' | |||||
| try { | |||||
| const [myRes, listRes] = await Promise.all([ | |||||
| post('/invite/my'), | |||||
| post('/invite/list', { page_no: 1, page_size: 4 }), | |||||
| ]) | |||||
| config.value = myRes.data?.config || {} | |||||
| stats.value = myRes.data?.stats || {} | |||||
| inviteCode.value = myRes.data?.invite_code || '' | |||||
| recentList.value = listRes.list || [] | |||||
| loaded.value = true | |||||
| } catch (error) { | |||||
| loadError.value = error.message || '网络开小差了,请稍后重试。' | |||||
| } finally { | |||||
| loading.value = false | |||||
| } | |||||
| } | |||||
| async function copyText(text) { | |||||
| if (navigator.clipboard && window.isSecureContext) { | |||||
| await navigator.clipboard.writeText(text) | |||||
| return | |||||
| } | |||||
| const input = document.createElement('textarea') | |||||
| input.value = text | |||||
| input.style.position = 'fixed' | |||||
| input.style.opacity = '0' | |||||
| document.body.appendChild(input) | |||||
| input.select() | |||||
| document.execCommand('copy') | |||||
| input.remove() | |||||
| } | |||||
| async function copyLink() { | |||||
| if (!activityEnabled.value) return | |||||
| try { | |||||
| await copyText(inviteUrl.value) | |||||
| toast.add({ title: '邀请链接已复制', color: 'success' }) | |||||
| } catch { | |||||
| toast.add({ title: '复制失败,请手动复制链接', color: 'error' }) | |||||
| } | |||||
| } | |||||
| async function shareLink() { | |||||
| if (!activityEnabled.value) return | |||||
| if (navigator.share) { | |||||
| try { | |||||
| await navigator.share({ | |||||
| title: config.value.share_title || '邀请你体验 AI ONLINE', | |||||
| text: config.value.share_desc || '登录注册即可领取 AI 创作点数', | |||||
| url: inviteUrl.value, | |||||
| }) | |||||
| return | |||||
| } catch (error) { | |||||
| if (error?.name === 'AbortError') return | |||||
| } | |||||
| } | |||||
| await copyLink() | |||||
| } | |||||
| async function openQr() { | |||||
| if (!activityEnabled.value) return | |||||
| qrOpen.value = true | |||||
| if (qrcodeUrl.value || qrLoading.value) return | |||||
| qrLoading.value = true | |||||
| try { | |||||
| const res = await post('/invite/qrcode') | |||||
| qrcodeUrl.value = res.data?.qrcode_url || '' | |||||
| } catch (error) { | |||||
| toast.add({ title: error.message || '小程序码生成失败', color: 'error' }) | |||||
| } finally { | |||||
| qrLoading.value = false | |||||
| } | |||||
| } | |||||
| function getInitial(name = '') { | |||||
| return String(name || '友').slice(0, 1) | |||||
| } | |||||
| function formatTime(value) { | |||||
| if (!value) return '' | |||||
| return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm') | |||||
| } | |||||
| onMounted(loadData) | |||||
| watch(isLogin, (value) => { | |||||
| if (value) loadData() | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,156 @@ | |||||
| <template> | |||||
| <div class="relative overflow-hidden bg-gradient-to-b from-[var(--c-eaf5ff)] via-[var(--c-f5f9fd)] to-[var(--c-f3f7fb)]"> | |||||
| <div class="pointer-events-none absolute -right-24 top-10 size-72 rounded-full bg-[var(--c-36b7ff)]/15 blur-3xl" /> | |||||
| <div class="pointer-events-none absolute -left-28 top-72 size-72 rounded-full bg-[var(--c-0b8cff)]/10 blur-3xl" /> | |||||
| <main class="relative mx-auto grid min-h-[calc(100vh-64px)] max-w-7xl items-center gap-8 px-4 py-10 sm:px-6 lg:grid-cols-12 lg:px-8 lg:py-16"> | |||||
| <section class="lg:col-span-7 lg:pr-8" aria-labelledby="landing-title"> | |||||
| <div class="inline-flex items-center gap-2 rounded-full border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/80 px-3.5 py-2 text-xs font-extrabold text-[var(--c-0b8cff)] shadow-[0_6px_18px_var(--sh-11-140-255-100)] backdrop-blur"> | |||||
| <UIcon name="i-lucide-gift" class="size-3.5" /> | |||||
| 新用户邀请礼 | |||||
| </div> | |||||
| <div class="mt-7 flex items-center gap-3"> | |||||
| <div class="flex size-12 items-center justify-center overflow-hidden rounded-2xl bg-[var(--c-ffffff)] text-sm font-extrabold text-[var(--c-0b8cff)] shadow-[0_10px_24px_var(--sh-23-58-110-100)] ring-1 ring-[var(--c-e4edf7)]"> | |||||
| <img v-if="inviter.avatar" :src="inviter.avatar" :alt="inviter.user_name || '邀请人'" class="size-full object-cover"> | |||||
| <span v-else>AI</span> | |||||
| </div> | |||||
| <div> | |||||
| <div class="text-xs text-[var(--c-8a97a8)]">来自好友的邀请</div> | |||||
| <div class="mt-1 text-sm font-extrabold text-[var(--c-172033)]">{{ inviter.user_name || 'AI 在线好友' }}</div> | |||||
| </div> | |||||
| </div> | |||||
| <h1 id="landing-title" class="mt-7 max-w-2xl text-4xl font-extrabold leading-[1.12] tracking-tight text-[var(--c-172033)] sm:text-5xl lg:text-[54px]"> | |||||
| {{ landingTitle }} | |||||
| </h1> | |||||
| <p class="mt-5 max-w-xl text-sm leading-7 text-[var(--c-5f6f84)] sm:text-base">{{ landingDesc }}</p> | |||||
| <div class="mt-7 flex flex-wrap gap-3 text-xs font-bold text-[var(--c-66758a)]"> | |||||
| <span v-for="item in benefits" :key="item" class="inline-flex items-center gap-1.5 rounded-full bg-[var(--c-ffffff)]/80 px-3 py-2 shadow-sm"> | |||||
| <UIcon name="i-lucide-check" class="size-3.5 text-[var(--c-17a65a)]" /> | |||||
| {{ item }} | |||||
| </span> | |||||
| </div> | |||||
| </section> | |||||
| <section class="lg:col-span-5" aria-label="邀请奖励"> | |||||
| <div class="rounded-[30px] border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/90 p-5 shadow-[0_26px_70px_var(--sh-28-69-120-130)] backdrop-blur sm:p-7"> | |||||
| <div v-if="pending" class="space-y-4" role="status" aria-label="正在加载邀请信息"> | |||||
| <div class="mx-auto size-16 animate-pulse rounded-2xl bg-[var(--c-edf2f7)] motion-reduce:animate-none" /> | |||||
| <div class="mx-auto h-5 w-40 animate-pulse rounded-full bg-[var(--c-edf2f7)] motion-reduce:animate-none" /> | |||||
| <div class="grid grid-cols-2 gap-3"> | |||||
| <div class="h-32 animate-pulse rounded-2xl bg-[var(--c-f1f5fa)] motion-reduce:animate-none" /> | |||||
| <div class="h-32 animate-pulse rounded-2xl bg-[var(--c-f1f5fa)] motion-reduce:animate-none" /> | |||||
| </div> | |||||
| </div> | |||||
| <template v-else> | |||||
| <div class="text-center"> | |||||
| <div | |||||
| class="mx-auto flex size-16 items-center justify-center rounded-2xl" | |||||
| :class="validInvite && activityEnabled ? 'bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-8 ring-[var(--c-f7fbff)]' : 'bg-[var(--c-f1f5fa)] text-[var(--c-8a97a8)] ring-8 ring-[var(--c-fafbfd)]'" | |||||
| > | |||||
| <UIcon :name="validInvite && activityEnabled ? 'i-lucide-party-popper' : 'i-lucide-link-2-off'" class="size-7" /> | |||||
| </div> | |||||
| <h2 class="mt-6 text-xl font-extrabold text-[var(--c-172033)]"> | |||||
| {{ validInvite && activityEnabled ? '登录即可领取邀请礼' : invalidTitle }} | |||||
| </h2> | |||||
| <p class="mt-2 text-sm leading-6 text-[var(--c-7b8797)]"> | |||||
| {{ validInvite && activityEnabled ? '奖励将自动发放到双方的点数账户。' : invalidDesc }} | |||||
| </p> | |||||
| </div> | |||||
| <div v-if="validInvite && activityEnabled" class="mt-6 grid grid-cols-2 gap-3"> | |||||
| <div class="rounded-2xl bg-[var(--c-f0f8ff)] px-3 py-5 text-center ring-1 ring-inset ring-[var(--c-dceefe)]"> | |||||
| <div class="text-3xl font-extrabold text-[var(--c-0b8cff)] tabular-nums">{{ config.invitee_points || 0 }}</div> | |||||
| <div class="mt-2 text-xs font-bold text-[var(--c-66758a)]">你可领取点数</div> | |||||
| </div> | |||||
| <div class="rounded-2xl bg-[var(--c-edfdf4)] px-3 py-5 text-center ring-1 ring-inset ring-[var(--c-d8f4e5)]"> | |||||
| <div class="text-3xl font-extrabold text-[var(--c-159a54)] tabular-nums">{{ config.inviter_points || 0 }}</div> | |||||
| <div class="mt-2 text-xs font-bold text-[var(--c-66758a)]">好友获得点数</div> | |||||
| </div> | |||||
| </div> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-6 flex h-13 w-full cursor-pointer items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] text-base font-extrabold text-white shadow-[0_12px_26px_var(--sh-11-140-255-240)] transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2" | |||||
| @click="handleAction" | |||||
| > | |||||
| <UIcon :name="isLogin ? 'i-lucide-arrow-right' : validInvite && activityEnabled ? 'i-lucide-log-in' : 'i-lucide-house'" class="size-4.5" /> | |||||
| {{ actionText }} | |||||
| </button> | |||||
| <p v-if="validInvite && activityEnabled" class="mt-5 text-center text-[11px] leading-5 text-[var(--c-9aa5b5)]"> | |||||
| {{ config.rule_text || '好友通过你的邀请完成新用户注册后,你和好友都可获得点数奖励。' }} | |||||
| </p> | |||||
| </template> | |||||
| </div> | |||||
| </section> | |||||
| </main> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| const route = useRoute() | |||||
| const { post } = useApi() | |||||
| const { isLogin, showLogin } = useUser() | |||||
| const { clearInvite, normalizeInviteCode } = useInvite() | |||||
| const rawCode = route.query.invite_code || route.query.invite_open_id || '' | |||||
| const code = normalizeInviteCode(rawCode) | |||||
| const { data: inviteData, pending, error } = await useAsyncData( | |||||
| `invite-landing-${code || 'empty'}`, | |||||
| () => post('/invite/config', { | |||||
| invite_code: code, | |||||
| scene: String(route.query.scene || ''), | |||||
| }), | |||||
| ) | |||||
| const config = computed(() => inviteData.value?.data?.config || {}) | |||||
| const inviter = computed(() => inviteData.value?.data?.inviter || {}) | |||||
| const validInvite = computed(() => Boolean(code && inviter.value.user_id)) | |||||
| const activityEnabled = computed(() => Number(config.value.status ?? 1) === 1) | |||||
| const landingTitle = computed(() => { | |||||
| if (validInvite.value && activityEnabled.value) { | |||||
| return config.value.landing_title || '好友邀请你一起体验 AI 创作' | |||||
| } | |||||
| return '发现更多好用的 AI 创作工具' | |||||
| }) | |||||
| const landingDesc = computed(() => { | |||||
| if (validInvite.value && activityEnabled.value) { | |||||
| return config.value.landing_desc || '登录后领取点数奖励,AI 取名、动漫头像、证件照等功能都能使用。' | |||||
| } | |||||
| return 'AI 在线提供取名、动漫头像与图像创作等能力,登录后即可开始体验。' | |||||
| }) | |||||
| const invalidTitle = computed(() => { | |||||
| if (error.value) return '邀请信息加载失败' | |||||
| if (!activityEnabled.value) return '邀请活动暂未开放' | |||||
| return '邀请链接无效或已过期' | |||||
| }) | |||||
| const invalidDesc = computed(() => error.value ? '网络开小差了,你仍然可以直接进入 AI 在线。' : '你仍然可以直接进入 AI 在线,体验全部创作工具。') | |||||
| const actionText = computed(() => { | |||||
| if (isLogin.value) return '进入 AI 在线' | |||||
| if (validInvite.value && activityEnabled.value) return '登录并领取' | |||||
| return '直接体验' | |||||
| }) | |||||
| const benefits = ['点数多端通用', '无需订阅', '创作记录同步'] | |||||
| useSeoMeta({ | |||||
| title: () => `${landingTitle.value} - AI在线`, | |||||
| description: () => landingDesc.value, | |||||
| robots: 'noindex, nofollow', | |||||
| }) | |||||
| if (code && !pending.value && !validInvite.value) clearInvite() | |||||
| function handleAction() { | |||||
| if (isLogin.value || !validInvite.value || !activityEnabled.value) { | |||||
| navigateTo('/') | |||||
| return | |||||
| } | |||||
| showLogin.value = true | |||||
| } | |||||
| </script> | |||||
| @@ -0,0 +1,260 @@ | |||||
| <template> | |||||
| <div> | |||||
| <AppHero | |||||
| kicker="INVITE RECORDS" | |||||
| title="邀请记录" | |||||
| desc="查看好友注册状态与邀请奖励发放情况。" | |||||
| > | |||||
| <template #aside> | |||||
| <div class="w-full rounded-3xl border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/85 p-5 shadow-[0_18px_34px_var(--sh-0-92-190-100)] backdrop-blur lg:w-[380px]"> | |||||
| <div class="grid grid-cols-3 gap-3 text-center"> | |||||
| <div v-for="item in summaryCards" :key="item.label"> | |||||
| <div class="text-2xl font-extrabold tabular-nums" :class="item.tone">{{ loaded ? item.value : '—' }}</div> | |||||
| <div class="mt-1.5 text-[11px] font-bold text-[var(--c-8a97a8)]">{{ item.label }}</div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| </AppHero> | |||||
| <main class="mx-auto max-w-6xl px-4 py-7 sm:px-6 lg:px-8 lg:py-10"> | |||||
| <section | |||||
| v-if="!isLogin" | |||||
| class="rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] px-6 py-14 text-center shadow-[0_16px_40px_var(--sh-34-68-112-60)]" | |||||
| > | |||||
| <UIcon name="i-lucide-lock-keyhole" class="mx-auto size-10 text-[var(--c-a0aabb)]" /> | |||||
| <h2 class="mt-4 text-lg font-extrabold text-[var(--c-172033)]">登录后查看邀请记录</h2> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-6 cursor-pointer rounded-full bg-[var(--c-0b8cff)] px-7 py-2.5 text-sm font-extrabold text-white" | |||||
| @click="showLogin = true" | |||||
| > | |||||
| 立即登录 | |||||
| </button> | |||||
| </section> | |||||
| <section | |||||
| v-else | |||||
| class="overflow-hidden rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] shadow-[0_16px_40px_var(--sh-34-68-112-60)]" | |||||
| aria-labelledby="record-title" | |||||
| :aria-busy="loading" | |||||
| > | |||||
| <header class="flex flex-wrap items-end justify-between gap-4 border-b border-[var(--c-edf2f7)] px-5 py-5 sm:px-6"> | |||||
| <div> | |||||
| <h2 id="record-title" class="text-lg font-extrabold text-[var(--c-172033)]">全部记录</h2> | |||||
| <p class="mt-1 text-xs text-[var(--c-8a97a8)]">共 {{ total }} 条邀请,最近的记录排在最前。</p> | |||||
| </div> | |||||
| <NuxtLink | |||||
| to="/invite" | |||||
| class="inline-flex h-9 items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-4 text-xs font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)]" | |||||
| > | |||||
| <UIcon name="i-lucide-user-plus" class="size-3.5" /> | |||||
| 继续邀请 | |||||
| </NuxtLink> | |||||
| </header> | |||||
| <div v-if="initialLoading" class="divide-y divide-[var(--c-edf2f7)]" role="status" aria-label="正在加载邀请记录"> | |||||
| <div v-for="i in 5" :key="i" class="flex items-center gap-4 px-5 py-5 sm:px-6"> | |||||
| <div class="size-11 animate-pulse rounded-full bg-[var(--c-edf2f7)] motion-reduce:animate-none" /> | |||||
| <div class="min-w-0 flex-1"> | |||||
| <div class="h-3.5 w-28 animate-pulse rounded-full bg-[var(--c-edf2f7)] motion-reduce:animate-none" /> | |||||
| <div class="mt-3 h-3 w-48 max-w-full animate-pulse rounded-full bg-[var(--c-f1f5fa)] motion-reduce:animate-none" /> | |||||
| </div> | |||||
| <div class="h-5 w-16 animate-pulse rounded-full bg-[var(--c-edf2f7)] motion-reduce:animate-none" /> | |||||
| </div> | |||||
| </div> | |||||
| <div v-else-if="loadError && !list.length" class="px-5 py-16 text-center" role="alert"> | |||||
| <UIcon name="i-lucide-circle-alert" class="mx-auto size-9 text-[var(--c-e85f1c)]" /> | |||||
| <h3 class="mt-4 text-base font-extrabold text-[var(--c-172033)]">记录加载失败</h3> | |||||
| <p class="mt-2 text-sm text-[var(--c-8a97a8)]">{{ loadError }}</p> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-6 cursor-pointer rounded-full bg-[var(--c-eef7ff)] px-6 py-2.5 text-sm font-bold text-[var(--c-0b8cff)]" | |||||
| @click="fetchList(true)" | |||||
| > | |||||
| 重新加载 | |||||
| </button> | |||||
| </div> | |||||
| <div v-else-if="list.length"> | |||||
| <div class="hidden grid-cols-[minmax(0,1fr)_130px_150px] bg-[var(--c-fbfcfe)] px-6 py-2.5 text-[11px] font-bold text-[var(--c-9aa5b5)] sm:grid"> | |||||
| <span>受邀好友</span> | |||||
| <span>状态</span> | |||||
| <span class="text-right">邀请奖励</span> | |||||
| </div> | |||||
| <div class="divide-y divide-[var(--c-edf2f7)]"> | |||||
| <article | |||||
| v-for="item in list" | |||||
| :key="item.id" | |||||
| class="grid gap-4 px-5 py-5 transition-colors hover:bg-[var(--c-fbfdff)] sm:grid-cols-[minmax(0,1fr)_130px_150px] sm:items-center sm:px-6" | |||||
| > | |||||
| <div class="flex min-w-0 items-center gap-3.5"> | |||||
| <div class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--c-eef7ff)] text-sm font-extrabold text-[var(--c-0b8cff)]"> | |||||
| <img v-if="item.avatar" :src="item.avatar" :alt="item.user_name || '好友'" class="size-full object-cover"> | |||||
| <span v-else>{{ getInitial(item.user_name) }}</span> | |||||
| </div> | |||||
| <div class="min-w-0"> | |||||
| <h3 class="truncate text-sm font-extrabold text-[var(--c-172033)]">{{ item.user_name || '好友' }}</h3> | |||||
| <p class="mt-1 text-xs text-[var(--c-8a97a8)]">{{ recordDescription(item) }}</p> | |||||
| <p class="mt-1.5 inline-flex items-center gap-1 text-[11px] text-[var(--c-9aa5b5)]"> | |||||
| <UIcon name="i-lucide-clock-3" class="size-3" /> | |||||
| {{ formatTime(item.reward_time || item.add_time) }} | |||||
| </p> | |||||
| </div> | |||||
| </div> | |||||
| <div> | |||||
| <span class="inline-flex rounded-full px-2.5 py-1 text-[11px] font-extrabold" :class="statusTone(item.status)"> | |||||
| {{ item.status_text || statusText(item.status) }} | |||||
| </span> | |||||
| </div> | |||||
| <div class="flex items-center justify-between border-t border-dashed border-[var(--c-e8eef7)] pt-3 sm:block sm:border-0 sm:pt-0 sm:text-right"> | |||||
| <span class="text-[11px] text-[var(--c-9aa5b5)] sm:hidden">邀请奖励</span> | |||||
| <div> | |||||
| <div class="text-lg font-extrabold tabular-nums" :class="Number(item.status) === 1 ? 'text-[var(--c-159a54)]' : 'text-[var(--c-8a97a8)]'"> | |||||
| {{ Number(item.status) === 1 ? `+${item.inviter_points || 0}` : '—' }} | |||||
| </div> | |||||
| <div v-if="Number(item.status) === 1" class="mt-1 text-[11px] text-[var(--c-9aa5b5)]">点数已到账</div> | |||||
| </div> | |||||
| </div> | |||||
| </article> | |||||
| </div> | |||||
| </div> | |||||
| <div v-else class="px-5 py-16 text-center"> | |||||
| <div class="mx-auto flex size-14 items-center justify-center rounded-2xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-8 ring-[var(--c-f8fbff)]"> | |||||
| <UIcon name="i-lucide-user-round-plus" class="size-6" /> | |||||
| </div> | |||||
| <h3 class="mt-6 text-base font-extrabold text-[var(--c-172033)]">还没有邀请记录</h3> | |||||
| <p class="mt-2 text-sm text-[var(--c-8a97a8)]">分享邀请链接,好友注册成功后会展示在这里。</p> | |||||
| <NuxtLink to="/invite" class="mt-6 inline-flex h-10 items-center rounded-full bg-[var(--c-eef7ff)] px-5 text-sm font-bold text-[var(--c-0b8cff)]"> | |||||
| 去邀请好友 | |||||
| </NuxtLink> | |||||
| </div> | |||||
| <footer v-if="list.length" class="border-t border-[var(--c-edf2f7)] px-5 py-4 sm:px-6"> | |||||
| <div v-if="loading" class="flex items-center justify-center gap-2 text-xs font-bold text-[var(--c-8a97a8)]" role="status"> | |||||
| <span class="size-4 animate-spin rounded-full border-2 border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)] motion-reduce:animate-none" /> | |||||
| 正在加载更多 | |||||
| </div> | |||||
| <button | |||||
| v-else-if="hasMore" | |||||
| type="button" | |||||
| class="mx-auto flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-6 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)]" | |||||
| @click="loadMore" | |||||
| > | |||||
| 加载更多 | |||||
| <UIcon name="i-lucide-chevron-down" class="size-3.5" /> | |||||
| </button> | |||||
| <div v-else class="text-center text-[11px] text-[var(--c-a0aabb)]">没有更多记录了</div> | |||||
| </footer> | |||||
| </section> | |||||
| </main> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| import dayjs from 'dayjs' | |||||
| useSeoMeta({ | |||||
| title: '邀请记录 - AI在线', | |||||
| description: '查看好友邀请和点数奖励记录。', | |||||
| robots: 'noindex, nofollow', | |||||
| }) | |||||
| const { isLogin, showLogin } = useUser() | |||||
| const { post } = useApi() | |||||
| const list = ref([]) | |||||
| const stats = ref({}) | |||||
| const page = ref(1) | |||||
| const pageSize = 10 | |||||
| const total = ref(0) | |||||
| const loading = ref(false) | |||||
| const loaded = ref(false) | |||||
| const loadError = ref('') | |||||
| const initialLoading = computed(() => loading.value && !list.value.length) | |||||
| const hasMore = computed(() => list.value.length < total.value) | |||||
| const summaryCards = computed(() => [ | |||||
| { label: '全部邀请', value: stats.value.total || total.value || 0, tone: 'text-[var(--c-172033)]' }, | |||||
| { label: '成功邀请', value: stats.value.success || 0, tone: 'text-[var(--c-0b8cff)]' }, | |||||
| { label: '累计奖励', value: stats.value.inviter_points || 0, tone: 'text-[var(--c-159a54)]' }, | |||||
| ]) | |||||
| async function fetchList(reset = false) { | |||||
| if (!isLogin.value || loading.value) return false | |||||
| if (reset) { | |||||
| page.value = 1 | |||||
| list.value = [] | |||||
| total.value = 0 | |||||
| } | |||||
| loading.value = true | |||||
| loadError.value = '' | |||||
| try { | |||||
| const res = await post('/invite/list', { | |||||
| page_no: page.value, | |||||
| page_size: pageSize, | |||||
| }) | |||||
| const rows = res.list || [] | |||||
| const data = res.data || {} | |||||
| list.value = reset ? rows : list.value.concat(rows) | |||||
| total.value = Number(data.total ?? rows.length) | |||||
| stats.value = data.stats || {} | |||||
| loaded.value = true | |||||
| return true | |||||
| } catch (error) { | |||||
| loadError.value = error.message || '网络开小差了,请稍后重试。' | |||||
| return false | |||||
| } finally { | |||||
| loading.value = false | |||||
| } | |||||
| } | |||||
| async function loadMore() { | |||||
| if (loading.value || !hasMore.value) return | |||||
| page.value += 1 | |||||
| const success = await fetchList() | |||||
| if (!success) page.value -= 1 | |||||
| } | |||||
| function getInitial(name = '') { | |||||
| return String(name || '友').slice(0, 1) | |||||
| } | |||||
| function formatTime(value) { | |||||
| if (!value) return '时间未知' | |||||
| return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm') | |||||
| } | |||||
| function statusText(status) { | |||||
| const value = Number(status) | |||||
| if (value === 1) return '已奖励' | |||||
| if (value === 2) return '已记录' | |||||
| if (value < 0) return '发放失败' | |||||
| return '待发放' | |||||
| } | |||||
| function statusTone(status) { | |||||
| const value = Number(status) | |||||
| if (value === 1) return 'bg-[var(--c-edfdf4)] text-[var(--c-159a54)]' | |||||
| if (value === 0) return 'bg-[var(--c-fff4ec)] text-[var(--c-d95414)]' | |||||
| if (value < 0) return 'bg-[var(--c-fff1f0)] text-[var(--c-d92d20)]' | |||||
| return 'bg-[var(--c-f1f5fa)] text-[var(--c-66758a)]' | |||||
| } | |||||
| function recordDescription(item) { | |||||
| const value = Number(item.status) | |||||
| if (value === 1) return '好友注册成功,奖励已发放' | |||||
| if (value === 2) return '邀请已记录,本次未触发奖励' | |||||
| if (value < 0) return item.remark || '奖励发放失败' | |||||
| return '等待系统发放奖励' | |||||
| } | |||||
| onMounted(() => fetchList(true)) | |||||
| watch(isLogin, (value) => { | |||||
| if (value) fetchList(true) | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,17 @@ | |||||
| <template> | |||||
| <div /> | |||||
| </template> | |||||
| <script setup> | |||||
| import { articleUrl, useArticleDetail } from '~/composables/useArticle' | |||||
| /** | |||||
| * 旧 sitemap 与历史内链使用 /news/detail/{id}.html。 | |||||
| * 先按文章 id 查询所属栏目,再服务端 301 到唯一的 /info/... 规范地址。 | |||||
| */ | |||||
| const nuxtApp = useNuxtApp() | |||||
| const { detail } = await useArticleDetail() | |||||
| await nuxtApp.runWithContext(() => | |||||
| navigateTo(articleUrl(detail.value), { redirectCode: 301, replace: true })) | |||||
| </script> | |||||
| @@ -0,0 +1,717 @@ | |||||
| <template> | |||||
| <div class="min-h-screen bg-[var(--c-f3f7fb)]"> | |||||
| <!-- ========== Hero ========== --> | |||||
| <AppHero | |||||
| kicker="OLD PHOTO RESTORE" | |||||
| title="老照片修复 / 上色" | |||||
| desc="黑白、泛黄、模糊、有划痕的照片都能修。AI 会重绘上色,五官可能有轻微变化。" | |||||
| > | |||||
| <template #actions> | |||||
| <button | |||||
| v-if="hasDemo" | |||||
| type="button" | |||||
| class="h-10 cursor-pointer rounded-full bg-[var(--c-0b8cff)] px-5 text-sm font-extrabold text-white shadow-[0_10px_20px_var(--sh-11-140-255-220)] transition-colors hover:bg-[var(--c-0a7ce0)] lg:h-11" | |||||
| @click="openDemoCompare" | |||||
| > | |||||
| 查看对比 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="h-10 cursor-pointer rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-0b8cff)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)] lg:h-11" | |||||
| @click="triggerUpload" | |||||
| > | |||||
| 上传照片 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-5b6b80)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)] lg:h-11" | |||||
| @click="historyOpen = true" | |||||
| > | |||||
| <UIcon name="i-lucide-history" class="size-4" /> | |||||
| 历史记录 | |||||
| </button> | |||||
| </template> | |||||
| <template #aside> | |||||
| <HeroComparePreview | |||||
| v-if="hasDemo" | |||||
| :before="demoPair.original" | |||||
| :after="demoPair.result" | |||||
| after-label="修复" | |||||
| @open="openDemoCompare" | |||||
| /> | |||||
| </template> | |||||
| </AppHero> | |||||
| <!-- ========== Main Grid ========== --> | |||||
| <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:py-8"> | |||||
| <div class="lg:grid lg:grid-cols-12 lg:gap-8"> | |||||
| <!-- ===== LEFT: 表单 ===== --> | |||||
| <div class="space-y-5 pb-28 lg:col-span-7 lg:pb-0"> | |||||
| <!-- 1. 上传 --> | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6"> | |||||
| <div class="mb-4 flex items-start justify-between gap-4"> | |||||
| <div class="min-w-0"> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">老照片</h3> | |||||
| <p class="mt-1 text-xs text-[var(--c-8a97a8)]">支持黑白、泛黄、模糊、划痕或破损照片,单张不超过 10M</p> | |||||
| </div> | |||||
| <span class="flex-shrink-0 rounded-full bg-[var(--c-fff1f1)] px-3 py-1 text-[11px] font-bold text-[var(--c-ff6b6b)]">必选</span> | |||||
| </div> | |||||
| <div | |||||
| class="flex cursor-pointer items-center gap-4 rounded-2xl border-2 border-[var(--c-e5edf7)] bg-[var(--c-f7faff)] p-4 transition-colors hover:border-[var(--c-1f8cff)]/40" | |||||
| role="button" | |||||
| tabindex="0" | |||||
| @click="triggerUpload" | |||||
| @keydown.enter.prevent="triggerUpload" | |||||
| @keydown.space.prevent="triggerUpload" | |||||
| @dragover.prevent | |||||
| @drop.prevent="onDrop" | |||||
| > | |||||
| <div class="relative h-28 w-20 flex-shrink-0 overflow-hidden rounded-2xl bg-[var(--c-edf3fb)]"> | |||||
| <img v-if="previewImage" :src="previewImage" class="h-full w-full object-cover" alt="已选照片预览"> | |||||
| <div v-else class="flex h-full w-full items-center justify-center"> | |||||
| <span class="flex size-9 items-center justify-center rounded-full bg-[var(--c-1f8cff)] text-2xl font-light text-white">+</span> | |||||
| </div> | |||||
| </div> | |||||
| <div class="min-w-0 flex-1"> | |||||
| <div class="text-sm font-extrabold text-[var(--c-172033)] lg:text-base">{{ previewImage ? '已选择照片' : '选择需要修复的老照片' }}</div> | |||||
| <div class="mt-1.5 text-xs text-[var(--c-8a97a8)]">建议主体清晰、人物未被严重遮挡;也可以直接把文件拖进来</div> | |||||
| </div> | |||||
| <div class="flex h-9 w-16 flex-shrink-0 items-center justify-center rounded-full bg-[var(--c-1f8cff)] text-xs font-extrabold text-white"> | |||||
| {{ previewImage ? '更换' : '上传' }} | |||||
| </div> | |||||
| </div> | |||||
| <input ref="fileInput" type="file" accept="image/*" class="hidden" aria-label="选择老照片" @change="onFileChange"> | |||||
| <div class="mt-3 flex flex-wrap gap-2.5"> | |||||
| <span class="rounded-full bg-[var(--c-f1f5fa)] px-3 py-1 text-[11px] font-bold text-[var(--c-526174)]">黑白照片</span> | |||||
| <span class="rounded-full bg-[var(--c-f1f5fa)] px-3 py-1 text-[11px] font-bold text-[var(--c-526174)]">泛黄褪色</span> | |||||
| <span class="rounded-full bg-[var(--c-f1f5fa)] px-3 py-1 text-[11px] font-bold text-[var(--c-526174)]">划痕破损</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 2. 修复档位 --> | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6"> | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">修复档位</h3> | |||||
| <span class="mt-1 text-xs text-[var(--c-8a97a8)]">按张计费</span> | |||||
| </div> | |||||
| <!-- 加载骨架:>300ms 的等待都要有反馈 --> | |||||
| <div v-if="configLoading" class="mt-4 space-y-2.5"> | |||||
| <div v-for="i in 3" :key="i" class="h-[76px] animate-pulse rounded-2xl bg-[var(--c-f1f5fa)]" /> | |||||
| </div> | |||||
| <div v-else class="mt-4 space-y-2.5"> | |||||
| <button | |||||
| v-for="item in modes" | |||||
| :key="item.key" | |||||
| type="button" | |||||
| class="flex w-full cursor-pointer items-center gap-4 rounded-2xl border-2 p-4 text-left transition-colors" | |||||
| :class="activeMode === item.key | |||||
| ? 'border-[var(--c-1f8cff)] bg-[var(--c-eef7ff)]' | |||||
| : 'border-transparent bg-[var(--c-f6f9fd)] hover:border-[var(--c-e5edf7)]'" | |||||
| :aria-pressed="activeMode === item.key" | |||||
| @click="activeMode = item.key" | |||||
| > | |||||
| <span | |||||
| class="flex size-5 flex-shrink-0 items-center justify-center rounded-full border-2 transition-colors" | |||||
| :class="activeMode === item.key ? 'border-[var(--c-1f8cff)]' : 'border-[var(--c-c3cddb)]'" | |||||
| > | |||||
| <span v-if="activeMode === item.key" class="size-2.5 rounded-full bg-[var(--c-1f8cff)]" /> | |||||
| </span> | |||||
| <span class="min-w-0 flex-1"> | |||||
| <span class="block text-sm font-extrabold text-[var(--c-172033)]">{{ item.name }}</span> | |||||
| <span class="mt-1 block text-xs leading-relaxed text-[var(--c-8a97a8)]">{{ item.desc }}</span> | |||||
| </span> | |||||
| <span class="flex-shrink-0 text-sm font-extrabold text-[var(--c-0b8cff)]">{{ item.points }}点</span> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 3. 补充说明 --> | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6"> | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">补充说明</h3> | |||||
| <span class="mt-1 text-xs text-[var(--c-8a97a8)]">选填</span> | |||||
| </div> | |||||
| <label class="mt-4 block text-xs font-bold text-[var(--c-526174)]" for="old-photo-extra">照片里的事实信息</label> | |||||
| <input | |||||
| id="old-photo-extra" | |||||
| v-model="extraPrompt" | |||||
| type="text" | |||||
| maxlength="100" | |||||
| placeholder="例如:军装是绿色的、背景是老家的院子" | |||||
| class="mt-2 h-12 w-full rounded-xl bg-[var(--c-f6f9fd)] px-4 text-sm text-[var(--c-172033)] placeholder:text-[var(--c-a0aabb)] focus:outline-none focus:ring-2 focus:ring-[var(--c-1f8cff)]/30" | |||||
| > | |||||
| <p class="mt-2 text-xs text-[var(--c-8a97a8)]">只用来补充客观信息,不要写风格化描述,避免影响人物还原。</p> | |||||
| </div> | |||||
| <!-- 4. 说明 --> | |||||
| <div class="space-y-3 rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6"> | |||||
| <div v-for="(tip, index) in tips" :key="tip" class="flex items-start"> | |||||
| <span class="mr-3 mt-0.5 flex h-5 w-7 flex-shrink-0 items-center justify-center rounded-[14px] bg-[var(--c-eef7ff)] text-[11px] font-extrabold text-[var(--c-1f8cff)]"> | |||||
| {{ String(index + 1).padStart(2, '0') }} | |||||
| </span> | |||||
| <span class="text-sm leading-relaxed text-[var(--c-5f6f83)]">{{ tip }}</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- Desktop 提交 --> | |||||
| <div class="hidden items-center gap-3 rounded-2xl bg-[var(--c-ffffff)] p-4 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:flex"> | |||||
| <div class="min-w-0 flex-1"> | |||||
| <div class="whitespace-nowrap text-sm font-bold text-[var(--c-172033)]">{{ balanceText }}</div> | |||||
| <button v-if="isBalanceInsufficient" type="button" class="mt-1 cursor-pointer text-xs text-[var(--c-f04438)] hover:underline" @click="goRecharge"> | |||||
| 点数不足,前往充值 | |||||
| </button> | |||||
| </div> | |||||
| <button | |||||
| type="button" | |||||
| class="flex h-14 flex-shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-[var(--c-e5edf7)] px-6 text-sm font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-f7f9fd)]" | |||||
| @click="historyOpen = true" | |||||
| > | |||||
| <UIcon name="i-lucide-history" class="size-4" /> | |||||
| 历史记录 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="flex h-14 min-w-[168px] flex-shrink-0 cursor-pointer items-center justify-center rounded-full px-10 text-base font-bold text-white shadow-[0_12px_24px_var(--sh-31-140-255-280)] transition-all disabled:cursor-not-allowed disabled:opacity-50" | |||||
| :class="submitting ? 'bg-[var(--c-1979e6)]' : 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] hover:opacity-90'" | |||||
| :disabled="!canSubmit" | |||||
| @click="handleSubmit" | |||||
| > | |||||
| <UIcon v-if="!submitting" name="i-lucide-zap" class="mr-1.5 size-4" /> | |||||
| <span>{{ submitting ? '提交中...' : submitText }}</span> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ===== RIGHT: 结果 ===== --> | |||||
| <div class="lg:col-span-5"> | |||||
| <div class="space-y-4 lg:sticky lg:top-20"> | |||||
| <!-- 空态 --> | |||||
| <div v-if="!resultState.visible" class="flex min-h-[300px] flex-col items-center justify-center rounded-2xl bg-[var(--c-ffffff)] p-8 text-center shadow-sm lg:p-10"> | |||||
| <UIcon name="i-lucide-image" class="mb-4 size-12 text-[var(--c-a0aabb)]" /> | |||||
| <div class="mb-1 text-base font-medium text-[var(--c-172033)]">等待修复</div> | |||||
| <div class="text-sm text-[var(--c-a0aabb)]">上传老照片并选择档位后</div> | |||||
| <div class="text-sm text-[var(--c-a0aabb)]">点击"立即修复"即可看到效果</div> | |||||
| </div> | |||||
| <!-- 处理中 --> | |||||
| <div v-if="resultState.loading" class="rounded-2xl bg-[var(--c-ffffff)] p-6 shadow-sm lg:p-8"> | |||||
| <div class="mb-1 text-base font-bold text-[var(--c-172033)]">{{ pendingTitle }}</div> | |||||
| <div class="mb-6 text-sm text-[var(--c-7f8896)]">混元生图未购买并发,高峰期会排队,请耐心等待</div> | |||||
| <div class="flex flex-col items-center rounded-2xl bg-[var(--c-f7f9fd)] py-10"> | |||||
| <div class="mb-4 flex gap-1.5"> | |||||
| <span class="animate-pulse-dot size-2.5 rounded-full bg-[var(--c-1f8cff)]" /> | |||||
| <span class="animate-pulse-dot2 size-2.5 rounded-full bg-[var(--c-1f8cff)]" /> | |||||
| <span class="animate-pulse-dot3 size-2.5 rounded-full bg-[var(--c-1f8cff)]" /> | |||||
| </div> | |||||
| <div class="text-sm font-bold text-[var(--c-172033)]">已等待 {{ waitedSeconds }}s</div> | |||||
| <div v-if="pollCountdown > 0" class="mt-2 text-xs text-[var(--c-8a95a6)]">下次刷新 {{ pollCountdown }}s</div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 失败 --> | |||||
| <div v-if="resultState.error" class="rounded-2xl bg-[var(--c-ffffff)] p-6 py-10 text-center shadow-sm lg:p-8"> | |||||
| <UIcon name="i-lucide-circle-alert" class="mx-auto mb-3 size-12 text-[var(--c-f04438)]" /> | |||||
| <div class="mb-2 text-base font-medium text-[var(--c-f04438)]">修复失败</div> | |||||
| <div class="mb-4 text-sm text-[var(--c-7f8896)]">{{ resultState.errorMsg }}</div> | |||||
| <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton> | |||||
| </div> | |||||
| <!-- 成功 --> | |||||
| <div v-if="resultState.success" class="space-y-4"> | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-sm lg:p-6"> | |||||
| <div class="mb-4 flex items-center justify-between gap-3"> | |||||
| <div class="text-base font-bold text-[var(--c-172033)]">修复结果</div> | |||||
| <span class="rounded-full bg-[var(--c-edfdf4)] px-2.5 py-1 text-[11px] font-extrabold text-[var(--c-159a54)]">{{ job.mode_name }}</span> | |||||
| </div> | |||||
| <!-- 主视图直接看成品,对比放进弹层 --> | |||||
| <div class="overflow-hidden rounded-xl bg-[var(--c-f7f9fd)]"> | |||||
| <img :src="job.result_url" class="w-full" alt="老照片修复结果"> | |||||
| </div> | |||||
| <div class="mt-5 flex gap-3"> | |||||
| <button | |||||
| type="button" | |||||
| class="flex h-12 flex-1 cursor-pointer items-center justify-center rounded-full border-2 border-[var(--c-1f8cff)] font-bold text-[var(--c-1f8cff)] transition-colors hover:bg-[var(--c-eef7ff)]" | |||||
| @click="openResultCompare" | |||||
| > | |||||
| <UIcon name="i-lucide-columns-2" class="mr-1.5 size-4" /> | |||||
| 查看对比 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="flex h-12 flex-1 cursor-pointer items-center justify-center rounded-full bg-[var(--c-1f8cff)] text-base font-bold text-white shadow-[0_8px_20px_var(--sh-31-140-255-200)] transition-colors hover:bg-[var(--c-1979e6)]" | |||||
| @click="downloadResult" | |||||
| > | |||||
| <UIcon name="i-lucide-download" class="mr-1.5 size-4" /> | |||||
| 下载 | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <button | |||||
| type="button" | |||||
| class="h-12 w-full cursor-pointer rounded-full border-2 border-[var(--c-1f8cff)] font-bold text-[var(--c-1f8cff)] transition-colors hover:bg-[var(--c-eef7ff)]" | |||||
| @click="resetJob" | |||||
| > | |||||
| 再修一张 | |||||
| </button> | |||||
| <p class="text-center text-xs leading-relaxed text-[var(--c-8a97a8)]"> | |||||
| AI 会重绘上色,五官可能出现轻微变化,建议对照原图确认 | |||||
| </p> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== 移动端底部操作条 ========== --> | |||||
| <div | |||||
| class="fixed bottom-0 left-0 right-0 z-50 bg-[var(--c-ffffff)]/95 shadow-[0_-10px_30px_var(--sh-32-56-92-100)] backdrop-blur lg:hidden" | |||||
| style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))" | |||||
| > | |||||
| <div class="mb-2.5 flex items-center justify-between gap-3 text-sm"> | |||||
| <div class="min-w-0 truncate text-[var(--c-7b8797)]">{{ balanceText }}</div> | |||||
| <button v-if="isBalanceInsufficient" type="button" class="flex-shrink-0 cursor-pointer text-xs font-extrabold text-[var(--c-1f8cff)]" @click="goRecharge"> | |||||
| 去充值 | |||||
| </button> | |||||
| </div> | |||||
| <div class="flex gap-3"> | |||||
| <button type="button" class="h-12 w-[120px] cursor-pointer rounded-full bg-[var(--c-eef7ff)] text-sm font-extrabold text-[var(--c-1f8cff)]" @click="historyOpen = true"> | |||||
| 历史记录 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="flex h-12 flex-1 cursor-pointer items-center justify-center rounded-full bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] text-sm font-extrabold text-white shadow-[0_12px_24px_var(--sh-31-140-255-280)] transition-opacity disabled:opacity-60" | |||||
| :disabled="!canSubmit" | |||||
| @click="handleSubmit" | |||||
| > | |||||
| <UIcon v-if="!submitting" name="i-lucide-zap" class="mr-1.5 size-4" /> | |||||
| {{ submitting ? '提交中...' : submitText }} | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== 历史记录 / 详情 ========== --> | |||||
| <WorkHistoryDrawer v-model="historyOpen" type="oldphoto" @select="openHistoryItem" /> | |||||
| <WorkDetailModal v-model="detailOpen" :work="currentWork" /> | |||||
| <!-- ========== 对比弹层(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-[var(--c-0f172a)]/60" @click="closeCompare" /> | |||||
| <div class="relative w-full max-w-2xl overflow-hidden rounded-2xl bg-[var(--c-ffffff)] shadow-2xl"> | |||||
| <div class="flex items-center justify-between border-b border-[var(--c-f3f4f6)] px-5 py-4"> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)]">{{ compareTarget === 'result' ? '修复结果对比' : '效果对比' }}</h3> | |||||
| <button | |||||
| type="button" | |||||
| class="flex size-8 cursor-pointer items-center justify-center rounded-full text-2xl leading-none text-[var(--c-a0aabb)] transition-colors hover:bg-[var(--c-f3f4f6)]" | |||||
| aria-label="关闭对比" | |||||
| @click="closeCompare" | |||||
| > | |||||
| × | |||||
| </button> | |||||
| </div> | |||||
| <div class="p-5"> | |||||
| <div | |||||
| ref="largeCompareRef" | |||||
| class="relative max-h-[70vh] w-full cursor-ew-resize select-none overflow-hidden rounded-2xl bg-[var(--c-dfe8f4)]" | |||||
| style="aspect-ratio: 3 / 4" | |||||
| @mousedown="onCompareStart" | |||||
| @touchstart.passive="onCompareStart" | |||||
| > | |||||
| <img :src="comparePair.after" class="pointer-events-none absolute inset-0 h-full w-full object-contain" alt="修复后"> | |||||
| <img | |||||
| :src="comparePair.before" | |||||
| class="pointer-events-none absolute inset-0 h-full w-full object-contain" | |||||
| :style="{ clipPath: `inset(0 ${100 - comparePercent}% 0 0)` }" | |||||
| alt="原图" | |||||
| > | |||||
| <div | |||||
| class="pointer-events-none absolute bottom-0 top-0 -ml-px w-1 bg-[var(--c-ffffff)]/95 shadow-[0_0_12px_var(--sh-20-37-66-220)]" | |||||
| :style="{ left: comparePercent + '%' }" | |||||
| > | |||||
| <div class="absolute left-1/2 top-1/2 flex h-14 w-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center gap-px rounded-full bg-[var(--c-ffffff)] text-lg font-extrabold text-[var(--c-1f8cff)] shadow-[0_8px_18px_var(--sh-20-37-66-220)]"> | |||||
| <span>‹</span><span>›</span> | |||||
| </div> | |||||
| </div> | |||||
| <div class="pointer-events-none absolute left-3 top-3 rounded-full bg-[var(--c-0f172a)]/50 px-2 py-0.5 text-[10px] font-bold text-white">原图</div> | |||||
| <div class="pointer-events-none absolute right-3 top-3 rounded-full bg-[var(--c-0f172a)]/50 px-2 py-0.5 text-[10px] font-bold text-white">修复后</div> | |||||
| </div> | |||||
| <div class="mt-3 text-center text-xs text-[var(--c-64748b)]">左右拖动中间按钮查看变化</div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </Transition> | |||||
| </Teleport> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| useSeoMeta({ | |||||
| title: '老照片修复上色 - AI在线', | |||||
| description: '上传黑白、泛黄、有划痕的老照片,AI 自动修复破损并自然上色,支持前后对比查看与原图下载。', | |||||
| }) | |||||
| const { post } = useApi() | |||||
| const { create: createOldPhoto, createRequestId } = useOldPhoto() | |||||
| const user = useUser() | |||||
| const router = useRouter() | |||||
| const toast = useToast() | |||||
| const POLL_INTERVAL = 3000 | |||||
| const POLL_LIMIT = 5 * 60 * 1000 | |||||
| const DEMO_CONFIG_KEY = 'old_photo_demo' | |||||
| const APP_KEY = 'old-photo' | |||||
| // ==================== State ==================== | |||||
| const fileInput = ref(null) | |||||
| const selectedFile = ref(null) | |||||
| const previewImage = ref('') | |||||
| const extraPrompt = ref('') | |||||
| const activeMode = ref('colorize') | |||||
| const modes = ref([]) | |||||
| const tips = ref([]) | |||||
| const configLoading = ref(true) | |||||
| const submitting = ref(false) | |||||
| const balance = reactive({ points: 0 }) | |||||
| const job = ref({}) | |||||
| const demoPair = ref({ original: '', result: '' }) | |||||
| const historyOpen = ref(false) | |||||
| const detailOpen = ref(false) | |||||
| const currentWork = ref({}) | |||||
| const resultState = ref({ visible: false, loading: false, error: false, success: false, errorMsg: '' }) | |||||
| const pollCountdown = ref(3) | |||||
| const waitedSeconds = ref(0) | |||||
| let pollTimer = null | |||||
| let countTimer = null | |||||
| let pollStart = 0 | |||||
| // ==================== Computed ==================== | |||||
| const hasDemo = computed(() => Boolean(demoPair.value.original && demoPair.value.result)) | |||||
| const currentMode = computed(() => modes.value.find(item => item.key === activeMode.value) || {}) | |||||
| const currentCost = computed(() => Number(currentMode.value.points || 0)) | |||||
| const currentPoints = computed(() => Number(balance.points || 0)) | |||||
| const isBalanceInsufficient = computed( | |||||
| () => user.isLogin.value && currentCost.value > 0 && currentPoints.value < currentCost.value, | |||||
| ) | |||||
| const canSubmit = computed(() => Boolean(selectedFile.value) && !submitting.value && !resultState.value.loading) | |||||
| const balanceText = computed(() => { | |||||
| const cost = currentCost.value | |||||
| if (!user.isLogin.value) return cost ? `老照片修复 · ${cost}点/张` : '老照片修复' | |||||
| return cost ? `余额${currentPoints.value}点 · 预计消耗${cost}点` : `余额${currentPoints.value}点` | |||||
| }) | |||||
| const submitText = computed(() => { | |||||
| const cost = currentCost.value | |||||
| if (!user.isLogin.value) return cost ? `登录后修复 · ${cost}点` : '登录后修复' | |||||
| return cost ? `立即修复 · ${cost}点` : '立即修复' | |||||
| }) | |||||
| const pendingTitle = computed(() => (Number(job.value.status) === 10 ? '排队中,等待 AI 处理' : 'AI 正在修复上色')) | |||||
| function showToast(message, color = 'error') { | |||||
| toast.add({ title: message, color }) | |||||
| } | |||||
| // ==================== 配置 / 示例 / 余额 ==================== | |||||
| async function loadConfig() { | |||||
| try { | |||||
| const res = await post('/oldphoto/config', { mode: activeMode.value }) | |||||
| const data = res.data || {} | |||||
| modes.value = data.modes || [] | |||||
| tips.value = data.tips || [] | |||||
| if (data.default_mode && !modes.value.some(item => item.key === activeMode.value)) { | |||||
| activeMode.value = data.default_mode | |||||
| } | |||||
| if (data.billing?.balance) { | |||||
| balance.points = data.billing.balance.points || data.billing.balance.point_balance || 0 | |||||
| } | |||||
| } catch (e) { | |||||
| showToast(e.message || '配置加载失败') | |||||
| } finally { | |||||
| configLoading.value = false | |||||
| } | |||||
| } | |||||
| /** 示例对比图走配置项 old_photo_demo,格式 [{ original, result }] */ | |||||
| async function loadDemo() { | |||||
| try { | |||||
| const res = await post('/common/getconfig', { keys: DEMO_CONFIG_KEY }) | |||||
| const rows = Array.isArray(res.list) ? res.list : [] | |||||
| const item = rows.find(it => it.key === DEMO_CONFIG_KEY) | |||||
| if (!item?.value) return | |||||
| const data = typeof item.value === 'string' ? JSON.parse(item.value) : item.value | |||||
| const first = Array.isArray(data) ? data[0] : data | |||||
| if (!first) return | |||||
| demoPair.value = { | |||||
| original: first.original || first.before || first.source || '', | |||||
| result: first.result || first.after || '', | |||||
| } | |||||
| } catch (e) { | |||||
| console.error('[old-photo] loadDemo failed:', e) | |||||
| } | |||||
| } | |||||
| async function loadBalance() { | |||||
| try { | |||||
| const res = await post('/point/balance', {}) | |||||
| if (res.code === 0 && res.data) balance.points = res.data.points || res.data.point_balance || 0 | |||||
| } catch (e) { | |||||
| console.error('[old-photo] loadBalance failed:', e) | |||||
| } | |||||
| user.refreshBalance() | |||||
| } | |||||
| onMounted(() => { | |||||
| loadConfig() | |||||
| loadDemo() | |||||
| if (user.isLogin.value) loadBalance() | |||||
| }) | |||||
| onUnmounted(() => clearPoll()) | |||||
| // ==================== 上传 ==================== | |||||
| function triggerUpload() { | |||||
| if (!user.isLogin.value) { | |||||
| user.showLogin.value = true | |||||
| return | |||||
| } | |||||
| fileInput.value?.click() | |||||
| } | |||||
| function onFileChange(e) { | |||||
| const file = e.target.files?.[0] | |||||
| if (file) handleFile(file) | |||||
| e.target.value = '' | |||||
| } | |||||
| function onDrop(e) { | |||||
| if (!user.isLogin.value) { | |||||
| user.showLogin.value = true | |||||
| return | |||||
| } | |||||
| const file = e.dataTransfer?.files?.[0] | |||||
| if (file) handleFile(file) | |||||
| } | |||||
| function handleFile(file) { | |||||
| if (!/^image\//.test(file.type)) return showToast('请选择图片文件') | |||||
| if (file.size > 10 * 1024 * 1024) return showToast('图片不能超过 10M') | |||||
| if (previewImage.value) URL.revokeObjectURL(previewImage.value) | |||||
| selectedFile.value = file | |||||
| previewImage.value = URL.createObjectURL(file) | |||||
| resultState.value = { visible: false, loading: false, error: false, success: false, errorMsg: '' } | |||||
| job.value = {} | |||||
| } | |||||
| // ==================== 提交与轮询 ==================== | |||||
| function clearPoll() { | |||||
| clearTimeout(pollTimer) | |||||
| clearInterval(countTimer) | |||||
| pollTimer = null | |||||
| countTimer = null | |||||
| } | |||||
| async function handleSubmit() { | |||||
| if (!user.isLogin.value) { | |||||
| user.showLogin.value = true | |||||
| return | |||||
| } | |||||
| if (!selectedFile.value) return showToast('请先上传老照片') | |||||
| if (!canSubmit.value) return | |||||
| if (isBalanceInsufficient.value) return showToast('点数不足,请充值后重试') | |||||
| submitting.value = true | |||||
| resultState.value = { visible: true, loading: true, error: false, success: false, errorMsg: '' } | |||||
| waitedSeconds.value = 0 | |||||
| pollStart = Date.now() | |||||
| try { | |||||
| const res = await createOldPhoto(selectedFile.value, { | |||||
| mode: activeMode.value, | |||||
| extra_prompt: extraPrompt.value, | |||||
| request_id: createRequestId(), | |||||
| }) | |||||
| job.value = res.data || {} | |||||
| if (Number(job.value.status) === 30) { | |||||
| onSuccess() | |||||
| } else { | |||||
| startPoll() | |||||
| } | |||||
| } catch (e) { | |||||
| const msg = e.code === 1011 ? '点数不足,请充值后重试' : (e.message || '提交失败,请稍后重试') | |||||
| resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: msg } | |||||
| } finally { | |||||
| submitting.value = false | |||||
| } | |||||
| } | |||||
| function startPoll() { | |||||
| clearPoll() | |||||
| pollCountdown.value = POLL_INTERVAL / 1000 | |||||
| countTimer = setInterval(() => { | |||||
| pollCountdown.value = Math.max(0, pollCountdown.value - 1) | |||||
| waitedSeconds.value = Math.round((Date.now() - pollStart) / 1000) | |||||
| }, 1000) | |||||
| pollTimer = setTimeout(async () => { | |||||
| if (Date.now() - pollStart > POLL_LIMIT) { | |||||
| clearPoll() | |||||
| resultState.value = { | |||||
| visible: true, | |||||
| loading: false, | |||||
| error: true, | |||||
| success: false, | |||||
| errorMsg: '生成时间较长,可稍后从历史记录查看结果', | |||||
| } | |||||
| return | |||||
| } | |||||
| try { | |||||
| const res = await post('/oldphoto/getdetail', { photo_open_id: job.value.photo_open_id }) | |||||
| const data = res.data || {} | |||||
| job.value = data | |||||
| const status = Number(data.status) | |||||
| if (status === 30) { | |||||
| clearPoll() | |||||
| onSuccess() | |||||
| } else if (status < 0) { | |||||
| clearPoll() | |||||
| resultState.value = { | |||||
| visible: true, | |||||
| loading: false, | |||||
| error: true, | |||||
| success: false, | |||||
| errorMsg: data.error_msg || '修复失败,本次未扣点', | |||||
| } | |||||
| } else { | |||||
| startPoll() | |||||
| } | |||||
| } catch (e) { | |||||
| // 单次查询失败不打断轮询 | |||||
| startPoll() | |||||
| } | |||||
| }, POLL_INTERVAL) | |||||
| } | |||||
| function onSuccess() { | |||||
| resultState.value = { visible: true, loading: false, error: false, success: true, errorMsg: '' } | |||||
| loadBalance() | |||||
| } | |||||
| function resetJob() { | |||||
| clearPoll() | |||||
| job.value = {} | |||||
| selectedFile.value = null | |||||
| if (previewImage.value) URL.revokeObjectURL(previewImage.value) | |||||
| previewImage.value = '' | |||||
| extraPrompt.value = '' | |||||
| resultState.value = { visible: false, loading: false, error: false, success: false, errorMsg: '' } | |||||
| } | |||||
| async function downloadResult() { | |||||
| if (!job.value.result_url) return | |||||
| try { | |||||
| const res = await fetch(job.value.result_url) | |||||
| const blob = await res.blob() | |||||
| const link = document.createElement('a') | |||||
| link.href = URL.createObjectURL(blob) | |||||
| link.download = `old-photo-${Date.now()}.jpg` | |||||
| link.click() | |||||
| URL.revokeObjectURL(link.href) | |||||
| } catch { | |||||
| window.open(job.value.result_url, '_blank') | |||||
| } | |||||
| } | |||||
| function goRecharge() { | |||||
| router.push('/recharge') | |||||
| } | |||||
| function openHistoryItem(item) { | |||||
| currentWork.value = item | |||||
| detailOpen.value = true | |||||
| } | |||||
| // ==================== 对比弹层 ==================== | |||||
| const compareVisible = ref(false) | |||||
| const comparePercent = ref(50) | |||||
| const compareTarget = ref('demo') | |||||
| const largeCompareRef = ref(null) | |||||
| let dragging = false | |||||
| const comparePair = computed(() => { | |||||
| if (compareTarget.value === 'result') { | |||||
| return { before: job.value.source_url || previewImage.value, after: job.value.result_url } | |||||
| } | |||||
| return { before: demoPair.value.original, after: demoPair.value.result } | |||||
| }) | |||||
| function openDemoCompare() { | |||||
| if (!hasDemo.value) return showToast('暂无对比图') | |||||
| comparePercent.value = 50 | |||||
| compareTarget.value = 'demo' | |||||
| compareVisible.value = true | |||||
| } | |||||
| function openResultCompare() { | |||||
| if (!job.value.result_url) return showToast('结果图暂不可用') | |||||
| comparePercent.value = 50 | |||||
| compareTarget.value = 'result' | |||||
| compareVisible.value = true | |||||
| } | |||||
| function closeCompare() { | |||||
| compareVisible.value = false | |||||
| onCompareEnd() | |||||
| } | |||||
| function onCompareStart(e) { | |||||
| dragging = true | |||||
| updateComparePercent(e.touches?.[0] || 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 percent = (((e.clientX || 0) - rect.left) / rect.width) * 100 | |||||
| comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1)))) | |||||
| } | |||||
| </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; } | |||||
| /* 尊重系统的减少动效设置 */ | |||||
| @media (prefers-reduced-motion: reduce) { | |||||
| .animate-pulse-dot, | |||||
| .animate-pulse-dot2, | |||||
| .animate-pulse-dot3 { animation: none; } | |||||
| .modal-enter-active, .modal-leave-active { transition: none; } | |||||
| } | |||||
| </style> | |||||
| @@ -0,0 +1,246 @@ | |||||
| <template> | |||||
| <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-10"> | |||||
| <nav aria-label="面包屑" class="flex items-center gap-1.5 text-xs text-[var(--c-8a97a8)]"> | |||||
| <NuxtLink to="/" class="hover:text-[var(--c-0b8cff)]">首页</NuxtLink> | |||||
| <span>/</span> | |||||
| <NuxtLink to="/pic/" class="hover:text-[var(--c-0b8cff)]">AI 图片</NuxtLink> | |||||
| <span>/</span> | |||||
| <span class="line-clamp-1 text-[var(--c-4b5b70)]">{{ pageTitle }}</span> | |||||
| </nav> | |||||
| <div class="mt-5 lg:grid lg:grid-cols-12 lg:gap-8"> | |||||
| <!-- 主图 --> | |||||
| <div class="lg:col-span-8"> | |||||
| <div class="overflow-hidden rounded-2xl bg-[var(--c-ffffff)] p-4 shadow-[0_8px_28px_var(--sh-34-68-112-50)] sm:p-6"> | |||||
| <div class="overflow-hidden rounded-xl bg-[var(--c-eef3f8)]"> | |||||
| <img | |||||
| v-if="currentImg" | |||||
| :src="currentImg" | |||||
| :alt="pageTitle" | |||||
| class="mx-auto max-h-[70vh] w-auto object-contain" | |||||
| > | |||||
| </div> | |||||
| <!-- 多图切换 --> | |||||
| <div v-if="picList.length > 1" class="mt-4 flex flex-wrap gap-2"> | |||||
| <button | |||||
| v-for="(img, idx) in picList" | |||||
| :key="idx" | |||||
| type="button" | |||||
| class="size-16 overflow-hidden rounded-lg border-2 transition-colors" | |||||
| :class="idx === imgIndex ? 'border-[var(--c-0b8cff)]' : 'border-transparent hover:border-[var(--c-e5edf7)]'" | |||||
| @click="imgIndex = idx" | |||||
| > | |||||
| <img :src="img" class="size-full object-cover" :alt="`图 ${idx + 1}`" loading="lazy"> | |||||
| </button> | |||||
| </div> | |||||
| <!-- 信息 --> | |||||
| <div class="mt-6 border-t border-[var(--c-eef2f7)] pt-5"> | |||||
| <h1 class="text-lg font-extrabold leading-snug text-[var(--c-172033)] sm:text-xl">{{ pageTitle }}</h1> | |||||
| <div class="mt-3 flex flex-wrap items-center gap-3 text-xs text-[var(--c-a0aabb)]"> | |||||
| <span class="rounded-full bg-[var(--c-f3f0ff)] px-2 py-0.5 font-bold text-[var(--c-7a5af8)]">{{ aiTypeName }}</span> | |||||
| <span v-if="detail.author_name" class="inline-flex items-center gap-1"> | |||||
| <UIcon name="i-lucide-user-round" class="size-3.5" /> | |||||
| {{ detail.author_name }} | |||||
| </span> | |||||
| <span class="inline-flex items-center gap-1"> | |||||
| <UIcon name="i-lucide-calendar" class="size-3.5" /> | |||||
| {{ formatTime(detail.add_time) }} | |||||
| </span> | |||||
| <span v-if="detail.view_num" class="inline-flex items-center gap-1"> | |||||
| <UIcon name="i-lucide-eye" class="size-3.5" /> | |||||
| {{ detail.view_num }} | |||||
| </span> | |||||
| <span v-if="detail.like_num" class="inline-flex items-center gap-1"> | |||||
| <UIcon name="i-lucide-heart" class="size-3.5" /> | |||||
| {{ detail.like_num }} | |||||
| </span> | |||||
| <span v-if="detail.resolution" class="inline-flex items-center gap-1"> | |||||
| <UIcon name="i-lucide-ratio" class="size-3.5" /> | |||||
| {{ detail.resolution }} | |||||
| </span> | |||||
| </div> | |||||
| <!-- 转化 --> | |||||
| <div class="mt-6 flex flex-wrap gap-3"> | |||||
| <NuxtLink | |||||
| to="/draw/anime/" | |||||
| class="inline-flex h-11 items-center gap-1.5 rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] px-6 text-sm font-bold text-white shadow-[0_10px_22px_var(--sh-11-140-255-240)] transition-opacity hover:opacity-90" | |||||
| > | |||||
| <UIcon name="i-lucide-zap" class="size-4" /> | |||||
| 我也要生成 | |||||
| </NuxtLink> | |||||
| <a | |||||
| v-if="currentImg" | |||||
| :href="currentImg" | |||||
| target="_blank" | |||||
| rel="noopener" | |||||
| class="inline-flex h-11 items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-6 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)]" | |||||
| > | |||||
| <UIcon name="i-lucide-external-link" class="size-4" /> | |||||
| 查看原图 | |||||
| </a> | |||||
| </div> | |||||
| <!-- 上一件 / 下一件(同时利于站内链接与收录) --> | |||||
| <div | |||||
| v-if="detail.prev_chat_open_id || detail.next_chat_open_id" | |||||
| class="mt-6 grid gap-3 border-t border-[var(--c-eef2f7)] pt-5 sm:grid-cols-2" | |||||
| > | |||||
| <NuxtLink | |||||
| v-if="detail.prev_chat_open_id" | |||||
| :to="`/pic/${detail.prev_chat_open_id}_0.html`" | |||||
| rel="prev" | |||||
| class="group flex items-center gap-2 rounded-xl bg-[var(--c-f7f9fd)] p-4 transition-colors hover:bg-[var(--c-eef7ff)]" | |||||
| > | |||||
| <UIcon name="i-lucide-chevron-left" class="size-4 shrink-0 text-[var(--c-a0aabb)]" /> | |||||
| <div class="min-w-0"> | |||||
| <div class="text-xs text-[var(--c-a0aabb)]">上一件作品</div> | |||||
| <div class="mt-0.5 text-sm font-bold text-[var(--c-4b5b70)] transition-colors group-hover:text-[var(--c-0b8cff)]">查看</div> | |||||
| </div> | |||||
| </NuxtLink> | |||||
| <NuxtLink | |||||
| v-if="detail.next_chat_open_id" | |||||
| :to="`/pic/${detail.next_chat_open_id}_0.html`" | |||||
| rel="next" | |||||
| class="group flex items-center justify-end gap-2 rounded-xl bg-[var(--c-f7f9fd)] p-4 text-right transition-colors hover:bg-[var(--c-eef7ff)] sm:col-start-2" | |||||
| > | |||||
| <div class="min-w-0"> | |||||
| <div class="text-xs text-[var(--c-a0aabb)]">下一件作品</div> | |||||
| <div class="mt-0.5 text-sm font-bold text-[var(--c-4b5b70)] transition-colors group-hover:text-[var(--c-0b8cff)]">查看</div> | |||||
| </div> | |||||
| <UIcon name="i-lucide-chevron-right" class="size-4 shrink-0 text-[var(--c-a0aabb)]" /> | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 相关作品 --> | |||||
| <div class="mt-8 lg:col-span-4 lg:mt-0"> | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_8px_28px_var(--sh-34-68-112-50)]"> | |||||
| <h2 class="text-base font-extrabold text-[var(--c-172033)]">更多 AI 作品</h2> | |||||
| <div v-if="otherList.length" class="mt-4 grid grid-cols-3 gap-2"> | |||||
| <NuxtLink | |||||
| v-for="item in otherList" | |||||
| :key="`${item.chat_open_id}_${item.img_index}`" | |||||
| :to="`/pic/${item.chat_open_id}_${item.img_index || 0}.html`" | |||||
| class="group aspect-square overflow-hidden rounded-lg bg-[var(--c-eef3f8)]" | |||||
| > | |||||
| <img | |||||
| :src="item.img" | |||||
| :alt="item.prompts || 'AI 作品'" | |||||
| class="size-full object-cover transition-transform duration-300 group-hover:scale-110" | |||||
| loading="lazy" | |||||
| > | |||||
| </NuxtLink> | |||||
| </div> | |||||
| <div v-else class="py-8 text-center text-sm text-[var(--c-a0aabb)]">暂无更多作品</div> | |||||
| <NuxtLink | |||||
| to="/pic/" | |||||
| class="mt-4 flex items-center justify-center gap-1 text-sm font-bold text-[var(--c-0b8cff)] hover:underline" | |||||
| > | |||||
| 查看全部图片 | |||||
| <UIcon name="i-lucide-arrow-right" class="size-4" /> | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| import dayjs from 'dayjs' | |||||
| import { legacyPageTitle } from '~/composables/useLegacySeo' | |||||
| // ai_type:1 gpt 2 MJ绘画 3/7/8 AI画头像 4 变年龄 5 变性别 6 AI绘画(沿用原站映射) | |||||
| const AI_TYPE_MAP = { | |||||
| 1: 'AI 问答', 2: 'MJ 绘画', 3: 'AI 画头像', 4: 'AI 变年龄', | |||||
| 5: 'AI 变性别', 6: 'AI 绘画', 7: 'AI 画头像', 8: 'AI 画头像', | |||||
| } | |||||
| const SEO_AI_TYPE_MAP = { | |||||
| 1: 'gpt', 2: 'MJ绘画', 3: 'AI画头像', 4: 'AI变年龄', | |||||
| 5: 'AI变性别', 6: 'AI绘画', 7: 'AI画头像', 8: 'AI画头像', | |||||
| } | |||||
| const route = useRoute() | |||||
| const { post } = useApi() | |||||
| // 原站 URL 形式:/pic/{chat_open_id}_{img_index}.html | |||||
| const raw = String(route.params.id || '').replace(/\.html$/i, '') | |||||
| const chatOpenId = raw.split('_')[0] | |||||
| const initialIndex = Number(raw.split('_')[1] || 0) | |||||
| const { data, error } = await useAsyncData(`pic-${raw}`, async () => { | |||||
| const detailRes = await post('/art/getdetail', { chat_open_id: chatOpenId, prev_next: 1 }) | |||||
| const d = detailRes.data || {} | |||||
| const otherRes = await post('/art/getlist', { page_no: 1, page_size: 12, curr_id: d.id }) | |||||
| .catch(() => ({ list: [] })) | |||||
| return { detail: d, otherList: (otherRes.list || []).filter(i => i.img).slice(0, 9) } | |||||
| }) | |||||
| if (error.value || !data.value?.detail) { | |||||
| throw createError({ statusCode: 404, statusMessage: '作品不存在', fatal: true }) | |||||
| } | |||||
| const detail = computed(() => data.value.detail || {}) | |||||
| const otherList = computed(() => data.value.otherList || []) | |||||
| const picList = computed(() => (detail.value.content ? String(detail.value.content).split(',') : [])) | |||||
| const imgIndex = ref(initialIndex) | |||||
| const currentImg = computed(() => picList.value[imgIndex.value] || picList.value[0] || '') | |||||
| const aiTypeName = computed(() => AI_TYPE_MAP[Number(detail.value.ai_type)] || 'AI 作品') | |||||
| const seoAiTypeName = computed(() => SEO_AI_TYPE_MAP[Number(detail.value.ai_type)] || 'AI作品') | |||||
| function formatTime(v) { | |||||
| return v ? dayjs(Number(v) * 1000).format('YYYY-MM-DD HH:mm') : '' | |||||
| } | |||||
| // 页面内展示标题保持现有样式。 | |||||
| const pageTitle = computed(() => { | |||||
| const p = String(detail.value.prompts || '').trim() | |||||
| if (p) return p | |||||
| const day = detail.value.add_time ? dayjs(Number(detail.value.add_time) * 1000).format('YYYYMMDD') : '' | |||||
| return `AI会员生成的${aiTypeName.value}(${day})` | |||||
| }) | |||||
| // TDK 严格沿用旧 PC 站 pic/detail controller 的生成规则。 | |||||
| const seoPageTitle = computed(() => { | |||||
| const prompts = String(detail.value.prompts || '').trim() | |||||
| if (prompts) return prompts | |||||
| const day = detail.value.add_time | |||||
| ? dayjs(Number(detail.value.add_time) * 1000).format('YYYYMMDD') | |||||
| : '' | |||||
| return `AI会员(${detail.value.author_open_id})生成的${seoAiTypeName.value}(${day}00${picList.value.length})` | |||||
| }) | |||||
| const seoDescription = computed(() => { | |||||
| const day = detail.value.add_time | |||||
| ? dayjs(Number(detail.value.add_time) * 1000).format('YYYYMMDD') | |||||
| : '' | |||||
| return `由会员(${detail.value.author_open_id})(${day})生成的${seoAiTypeName.value}` | |||||
| }) | |||||
| useSeoMeta({ | |||||
| title: () => legacyPageTitle(seoPageTitle.value), | |||||
| description: seoDescription, | |||||
| keywords: 'AI画头像', | |||||
| ogTitle: seoPageTitle, | |||||
| ogType: 'article', | |||||
| ogImage: currentImg, | |||||
| }) | |||||
| useHead({ | |||||
| link: [{ rel: 'canonical', href: `https://tool.aionline.cc/pic/${raw}.html` }], | |||||
| script: [{ | |||||
| type: 'application/ld+json', | |||||
| innerHTML: computed(() => JSON.stringify({ | |||||
| '@context': 'https://schema.org', | |||||
| '@type': 'ImageObject', | |||||
| name: pageTitle.value, | |||||
| contentUrl: currentImg.value, | |||||
| description: detail.value.prompts || aiTypeName.value, | |||||
| creator: { '@type': 'Organization', name: 'AI在线' }, | |||||
| })), | |||||
| }], | |||||
| }) | |||||
| </script> | |||||
| @@ -0,0 +1,98 @@ | |||||
| <template> | |||||
| <div> | |||||
| <!-- Hero --> | |||||
| <section class="border-b border-[var(--c-e8eef7)] bg-gradient-to-br from-[var(--c-ffffff)] via-[var(--c-f3f0ff)] to-[var(--c-eef6ff)]"> | |||||
| <div class="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8 lg:py-14"> | |||||
| <nav aria-label="面包屑" class="flex items-center gap-1.5 text-xs text-[var(--c-8a97a8)]"> | |||||
| <NuxtLink to="/" class="hover:text-[var(--c-0b8cff)]">首页</NuxtLink> | |||||
| <span>/</span> | |||||
| <span class="text-[var(--c-4b5b70)]">AI 图片</span> | |||||
| </nav> | |||||
| <h1 class="mt-4 text-3xl font-extrabold text-[var(--c-172033)] sm:text-4xl">AI 图片大全</h1> | |||||
| <p class="mt-3 max-w-2xl text-sm leading-relaxed text-[var(--c-66758a)] sm:text-base"> | |||||
| 精选 AI 在线用户生成的作品,包含 AI 绘画、AI 动漫头像等,点击查看提示词与大图。 | |||||
| </p> | |||||
| <div class="mt-6 flex flex-wrap gap-3"> | |||||
| <NuxtLink | |||||
| to="/draw/anime/" | |||||
| class="inline-flex h-10 items-center gap-1.5 rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] px-5 text-sm font-bold text-white shadow-[0_10px_22px_var(--sh-11-140-255-240)] transition-opacity hover:opacity-90" | |||||
| > | |||||
| <UIcon name="i-lucide-zap" class="size-4" /> | |||||
| 我也要生成 | |||||
| </NuxtLink> | |||||
| <NuxtLink | |||||
| to="/info" | |||||
| class="inline-flex h-10 items-center rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-bold text-[var(--c-5b6b80)] shadow-sm transition-colors hover:text-[var(--c-0b8cff)]" | |||||
| > | |||||
| 看 AI 资讯 | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </div> | |||||
| </section> | |||||
| <!-- 瀑布流图片 --> | |||||
| <div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 lg:py-12"> | |||||
| <div v-if="pending" class="columns-2 gap-4 sm:columns-3 lg:columns-4"> | |||||
| <div v-for="i in 12" :key="i" class="mb-4 h-56 animate-pulse rounded-2xl bg-[var(--c-ffffff)]" /> | |||||
| </div> | |||||
| <div v-else-if="list.length" class="columns-2 gap-4 sm:columns-3 lg:columns-4"> | |||||
| <NuxtLink | |||||
| v-for="item in list" | |||||
| :key="`${item.chat_open_id}_${item.img_index}`" | |||||
| :to="picUrl(item)" | |||||
| class="group mb-4 block break-inside-avoid overflow-hidden rounded-2xl bg-[var(--c-ffffff)] shadow-[0_8px_24px_var(--sh-34-68-112-60)] transition-shadow hover:shadow-[0_14px_34px_var(--sh-34-68-112-140)]" | |||||
| > | |||||
| <div class="relative overflow-hidden bg-[var(--c-eef3f8)]"> | |||||
| <img | |||||
| :src="item.img" | |||||
| :alt="item.prompts || 'AI 生成图片'" | |||||
| class="w-full transition-transform duration-500 group-hover:scale-105" | |||||
| loading="lazy" | |||||
| > | |||||
| </div> | |||||
| <div v-if="item.prompts" class="p-3"> | |||||
| <p class="line-clamp-2 text-xs leading-relaxed text-[var(--c-66758a)]">{{ item.prompts }}</p> | |||||
| </div> | |||||
| </NuxtLink> | |||||
| </div> | |||||
| <!-- 空态 --> | |||||
| <div v-else class="rounded-2xl bg-[var(--c-ffffff)] py-20 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]"> | |||||
| <UIcon name="i-lucide-image-off" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" /> | |||||
| <div class="text-base font-extrabold text-[var(--c-172033)]">暂无公开作品</div> | |||||
| <div class="mt-2 text-sm text-[var(--c-8a95a6)]">成为第一个分享 AI 作品的人</div> | |||||
| <NuxtLink | |||||
| to="/draw/anime/" | |||||
| class="mt-5 inline-flex h-10 items-center rounded-full bg-[var(--c-0b8cff)] px-6 text-sm font-extrabold text-white transition-colors hover:bg-[var(--c-0a7ce0)]" | |||||
| > | |||||
| 去创作 | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| const { post } = useApi() | |||||
| /** 详情 URL 与原站一致:/pic/{chat_open_id}_{img_index}.html */ | |||||
| function picUrl(item) { | |||||
| return `/pic/${item.chat_open_id}_${item.img_index || 0}.html` | |||||
| } | |||||
| await useLegacyAppSeo({ | |||||
| appKey: 'pic', | |||||
| fallbackTitle: 'AI动漫头像图片-AI作画生成动漫头像图片大全', | |||||
| fallbackDescription: 'AI图片通常指的是利用人工智能技术生成或编辑的图片。这种技术可以应用于多个领域,根据用户的需求或偏好,AI可以生成个性化的图片,例如定制的动漫头像、表情包等。', | |||||
| fallbackKeywords: '动漫头像,AI图片,头像图片,AI动漫,AI头像', | |||||
| canonicalPath: '/pic/', | |||||
| }) | |||||
| const { data, pending } = await useAsyncData('pic-list', () => | |||||
| post('/art/getlist', { page_no: 1, page_size: 30 }).catch(() => ({ list: [] }))) | |||||
| const list = computed(() => (data.value?.list || []).filter(i => i.img)) | |||||
| </script> | |||||
| @@ -0,0 +1,874 @@ | |||||
| <template> | |||||
| <div class="min-h-screen bg-[var(--c-f3f7fb)]"> | |||||
| <!-- ========== Hero ========== --> | |||||
| <AppHero | |||||
| kicker="PORTRAIT PHOTO STUDIO" | |||||
| title="形象照 / 职业照 / 写真" | |||||
| desc="上传一张人像照片,AI 重绘服装与背景,生成商务形象照、日常职业照或艺术写真。" | |||||
| > | |||||
| <template #actions> | |||||
| <button | |||||
| v-if="hasDemo" | |||||
| type="button" | |||||
| class="h-10 cursor-pointer rounded-full bg-[var(--c-0b8cff)] px-5 text-sm font-extrabold text-white shadow-[0_10px_20px_var(--sh-11-140-255-220)] transition-colors hover:bg-[var(--c-0a7ce0)] lg:h-11" | |||||
| @click="openDemoCompare" | |||||
| > | |||||
| 查看对比 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="h-10 cursor-pointer rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-0b8cff)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)] lg:h-11" | |||||
| @click="triggerUpload" | |||||
| > | |||||
| 上传照片 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-5b6b80)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)] lg:h-11" | |||||
| @click="historyOpen = true" | |||||
| > | |||||
| <UIcon name="i-lucide-history" class="size-4" /> | |||||
| 历史记录 | |||||
| </button> | |||||
| </template> | |||||
| <template #aside> | |||||
| <HeroComparePreview | |||||
| v-if="hasDemo" | |||||
| :before="demoPair.original" | |||||
| :after="demoPair.result" | |||||
| after-label="艺术" | |||||
| @open="openDemoCompare" | |||||
| /> | |||||
| </template> | |||||
| </AppHero> | |||||
| <!-- ========== Main Grid ========== --> | |||||
| <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:py-8"> | |||||
| <div class="lg:grid lg:grid-cols-12 lg:gap-8"> | |||||
| <!-- ===== LEFT: 表单 ===== --> | |||||
| <div class="space-y-5 pb-28 lg:col-span-7 lg:pb-0"> | |||||
| <!-- 1. 上传 --> | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6"> | |||||
| <div class="mb-4 flex items-start justify-between gap-4"> | |||||
| <div class="min-w-0"> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">人像照片</h3> | |||||
| <p class="mt-1 text-xs text-[var(--c-8a97a8)]">建议正面、五官无遮挡、光线均匀的半身照,单张不超过 10M</p> | |||||
| </div> | |||||
| <span class="flex-shrink-0 rounded-full bg-[var(--c-fff1f1)] px-3 py-1 text-[11px] font-bold text-[var(--c-ff6b6b)]">必选</span> | |||||
| </div> | |||||
| <div | |||||
| class="flex cursor-pointer items-center gap-4 rounded-2xl border-2 border-[var(--c-e5edf7)] bg-[var(--c-f7faff)] p-4 transition-colors hover:border-[var(--c-1f8cff)]/40" | |||||
| role="button" | |||||
| tabindex="0" | |||||
| @click="triggerUpload" | |||||
| @keydown.enter.prevent="triggerUpload" | |||||
| @keydown.space.prevent="triggerUpload" | |||||
| @dragover.prevent | |||||
| @drop.prevent="onDrop" | |||||
| > | |||||
| <div class="relative h-28 w-20 flex-shrink-0 overflow-hidden rounded-2xl bg-[var(--c-edf3fb)]"> | |||||
| <img v-if="previewImage" :src="previewImage" class="h-full w-full object-cover" alt="已选照片预览"> | |||||
| <div v-else class="flex h-full w-full items-center justify-center"> | |||||
| <span class="flex size-9 items-center justify-center rounded-full bg-[var(--c-1f8cff)] text-2xl font-light text-white">+</span> | |||||
| </div> | |||||
| </div> | |||||
| <div class="min-w-0 flex-1"> | |||||
| <div class="text-sm font-extrabold text-[var(--c-172033)] lg:text-base">{{ previewImage ? '已选择照片' : '上传正面清晰的人像照片' }}</div> | |||||
| <div class="mt-1.5 text-xs text-[var(--c-8a97a8)]">支持自拍与半身照;也可以直接把文件拖进来</div> | |||||
| </div> | |||||
| <div class="flex h-9 w-16 flex-shrink-0 items-center justify-center rounded-full bg-[var(--c-1f8cff)] text-xs font-extrabold text-white"> | |||||
| {{ previewImage ? '更换' : '上传' }} | |||||
| </div> | |||||
| </div> | |||||
| <input ref="fileInput" type="file" accept="image/*" class="hidden" aria-label="选择人像照片" @change="onFileChange"> | |||||
| </div> | |||||
| <!-- 2. 生成模型(只有一个模型时不展示,后续多模型自动出现) --> | |||||
| <div v-if="models.length > 1" class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6"> | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">生成模型</h3> | |||||
| <span class="mt-1 text-xs text-[var(--c-8a97a8)]">不同模型价格可能不同</span> | |||||
| </div> | |||||
| <div class="mt-4 grid gap-2.5 sm:grid-cols-2"> | |||||
| <button | |||||
| v-for="item in models" | |||||
| :key="item.key" | |||||
| type="button" | |||||
| class="cursor-pointer rounded-2xl border-2 p-4 text-left transition-colors" | |||||
| :class="activeModel === item.key | |||||
| ? 'border-[var(--c-1f8cff)] bg-[var(--c-eef7ff)]' | |||||
| : 'border-transparent bg-[var(--c-f6f9fd)] hover:border-[var(--c-e5edf7)]'" | |||||
| :aria-pressed="activeModel === item.key" | |||||
| @click="activeModel = item.key" | |||||
| > | |||||
| <span class="block text-sm font-extrabold text-[var(--c-172033)]">{{ item.name }}</span> | |||||
| <span v-if="item.desc" class="mt-1 block text-xs leading-relaxed text-[var(--c-8a97a8)]">{{ item.desc }}</span> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 3. 照片风格 + 选项 --> | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6"> | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">照片风格</h3> | |||||
| <span class="mt-1 text-xs text-[var(--c-8a97a8)]">选择生成方向</span> | |||||
| </div> | |||||
| <!-- 加载骨架:>300ms 的等待都要有反馈 --> | |||||
| <div v-if="configLoading" class="mt-4 space-y-2.5"> | |||||
| <div v-for="i in 3" :key="i" class="h-[76px] animate-pulse rounded-2xl bg-[var(--c-f1f5fa)]" /> | |||||
| </div> | |||||
| <template v-else> | |||||
| <div class="mt-4 grid gap-2.5 sm:grid-cols-3"> | |||||
| <button | |||||
| v-for="item in styles" | |||||
| :key="item.key" | |||||
| type="button" | |||||
| class="cursor-pointer rounded-2xl border-2 p-4 text-left transition-colors" | |||||
| :class="activeStyle === item.key | |||||
| ? 'border-[var(--c-1f8cff)] bg-[var(--c-eef7ff)]' | |||||
| : 'border-transparent bg-[var(--c-f6f9fd)] hover:border-[var(--c-e5edf7)]'" | |||||
| :aria-pressed="activeStyle === item.key" | |||||
| @click="selectStyle(item.key)" | |||||
| > | |||||
| <span class="block text-sm font-extrabold text-[var(--c-172033)]">{{ item.name }}</span> | |||||
| <span class="mt-1 block text-xs leading-relaxed text-[var(--c-8a97a8)]">{{ item.desc }}</span> | |||||
| </button> | |||||
| </div> | |||||
| <div v-for="group in currentGroups" :key="group.key" class="mt-5"> | |||||
| <div class="text-sm font-extrabold text-[var(--c-172033)]">{{ group.name }}</div> | |||||
| <div class="mt-2.5 flex flex-wrap gap-2.5"> | |||||
| <button | |||||
| v-for="item in group.items" | |||||
| :key="item.key" | |||||
| type="button" | |||||
| class="h-10 cursor-pointer rounded-full border-2 px-4 text-xs font-bold transition-colors" | |||||
| :class="options[group.key] === item.key | |||||
| ? 'border-[var(--c-1f8cff)] bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]' | |||||
| : 'border-transparent bg-[var(--c-f6f9fd)] text-[var(--c-526174)] hover:border-[var(--c-e5edf7)]'" | |||||
| :aria-pressed="options[group.key] === item.key" | |||||
| @click="options[group.key] = item.key" | |||||
| > | |||||
| {{ item.name }} | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| </template> | |||||
| </div> | |||||
| <!-- 4. 比例与张数 --> | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6"> | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">图片比例与张数</h3> | |||||
| <span class="mt-1 text-xs font-extrabold text-[var(--c-0b8cff)]">{{ unitPoints }}点/张 · 共{{ totalPoints }}点</span> | |||||
| </div> | |||||
| <div class="mt-4 flex flex-wrap gap-2.5"> | |||||
| <button | |||||
| v-for="item in ratios" | |||||
| :key="item.key" | |||||
| type="button" | |||||
| class="h-10 w-[76px] cursor-pointer rounded-xl border-2 text-xs font-extrabold transition-colors" | |||||
| :class="activeRatio === item.key | |||||
| ? 'border-[var(--c-1f8cff)] bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]' | |||||
| : 'border-transparent bg-[var(--c-f6f9fd)] text-[var(--c-526174)] hover:border-[var(--c-e5edf7)]'" | |||||
| :aria-pressed="activeRatio === item.key" | |||||
| @click="activeRatio = item.key" | |||||
| > | |||||
| {{ item.name }} | |||||
| </button> | |||||
| </div> | |||||
| <div class="mt-5 flex items-center gap-4"> | |||||
| <span class="text-sm font-bold text-[var(--c-526174)]">生成张数</span> | |||||
| <div class="flex items-center gap-3"> | |||||
| <button | |||||
| type="button" | |||||
| class="flex size-10 cursor-pointer items-center justify-center rounded-xl bg-[var(--c-eef7ff)] text-lg font-extrabold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)] disabled:cursor-not-allowed disabled:opacity-50" | |||||
| aria-label="减少张数" | |||||
| :disabled="quantity <= 1" | |||||
| @click="quantity = Math.max(1, quantity - 1)" | |||||
| > | |||||
| - | |||||
| </button> | |||||
| <span class="min-w-8 text-center text-lg font-extrabold text-[var(--c-172033)]">{{ quantity }}</span> | |||||
| <button | |||||
| type="button" | |||||
| class="flex size-10 cursor-pointer items-center justify-center rounded-xl bg-[var(--c-eef7ff)] text-lg font-extrabold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)] disabled:cursor-not-allowed disabled:opacity-50" | |||||
| aria-label="增加张数" | |||||
| :disabled="quantity >= maxCount" | |||||
| @click="quantity = Math.min(maxCount, quantity + 1)" | |||||
| > | |||||
| + | |||||
| </button> | |||||
| </div> | |||||
| <span class="text-xs text-[var(--c-8a97a8)]">一次最多 {{ maxCount }} 张,按张扣点,失败的不扣</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 5. 补充描述 --> | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6"> | |||||
| <div class="flex items-start justify-between gap-4"> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">补充描述</h3> | |||||
| <span class="mt-1 text-xs text-[var(--c-8a97a8)]">选填</span> | |||||
| </div> | |||||
| <label class="mt-4 block text-xs font-bold text-[var(--c-526174)]" for="portrait-extra">职业、服装、背景等客观信息</label> | |||||
| <input | |||||
| id="portrait-extra" | |||||
| v-model="extraPrompt" | |||||
| type="text" | |||||
| maxlength="100" | |||||
| placeholder="例如:程序员、深灰西装、浅色背景" | |||||
| class="mt-2 h-12 w-full rounded-xl bg-[var(--c-f6f9fd)] px-4 text-sm text-[var(--c-172033)] placeholder:text-[var(--c-a0aabb)] focus:outline-none focus:ring-2 focus:ring-[var(--c-1f8cff)]/30" | |||||
| > | |||||
| <p class="mt-2 text-xs text-[var(--c-8a97a8)]">只用来补充客观信息,风格由上方选项决定。</p> | |||||
| </div> | |||||
| <!-- 6. 说明 --> | |||||
| <div v-if="tips.length" class="space-y-3 rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6"> | |||||
| <div v-for="(tip, index) in tips" :key="tip" class="flex items-start"> | |||||
| <span class="mr-3 mt-0.5 flex h-5 w-7 flex-shrink-0 items-center justify-center rounded-[14px] bg-[var(--c-eef7ff)] text-[11px] font-extrabold text-[var(--c-1f8cff)]"> | |||||
| {{ String(index + 1).padStart(2, '0') }} | |||||
| </span> | |||||
| <span class="text-sm leading-relaxed text-[var(--c-5f6f83)]">{{ tip }}</span> | |||||
| </div> | |||||
| </div> | |||||
| <!-- Desktop 提交 --> | |||||
| <div class="hidden items-center gap-3 rounded-2xl bg-[var(--c-ffffff)] p-4 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:flex"> | |||||
| <div class="min-w-0 flex-1"> | |||||
| <div class="whitespace-nowrap text-sm font-bold text-[var(--c-172033)]">{{ balanceText }}</div> | |||||
| <button v-if="isBalanceInsufficient" type="button" class="mt-1 cursor-pointer text-xs text-[var(--c-f04438)] hover:underline" @click="goRecharge"> | |||||
| 点数不足,前往充值 | |||||
| </button> | |||||
| </div> | |||||
| <button | |||||
| type="button" | |||||
| class="flex h-14 flex-shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-[var(--c-e5edf7)] px-6 text-sm font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-f7f9fd)]" | |||||
| @click="historyOpen = true" | |||||
| > | |||||
| <UIcon name="i-lucide-history" class="size-4" /> | |||||
| 历史记录 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="flex h-14 min-w-[168px] flex-shrink-0 cursor-pointer items-center justify-center rounded-full px-10 text-base font-bold text-white shadow-[0_12px_24px_var(--sh-31-140-255-280)] transition-all disabled:cursor-not-allowed disabled:opacity-50" | |||||
| :class="submitting ? 'bg-[var(--c-1979e6)]' : 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] hover:opacity-90'" | |||||
| :disabled="!canSubmit" | |||||
| @click="handleSubmit" | |||||
| > | |||||
| <UIcon v-if="!submitting" name="i-lucide-zap" class="mr-1.5 size-4" /> | |||||
| <span>{{ submitting ? '提交中...' : submitText }}</span> | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ===== RIGHT: 结果 ===== --> | |||||
| <div class="lg:col-span-5"> | |||||
| <div class="space-y-4 lg:sticky lg:top-20"> | |||||
| <!-- 空态 --> | |||||
| <div v-if="!resultState.visible" class="flex min-h-[300px] flex-col items-center justify-center rounded-2xl bg-[var(--c-ffffff)] p-8 text-center shadow-sm lg:p-10"> | |||||
| <UIcon name="i-lucide-image" class="mb-4 size-12 text-[var(--c-a0aabb)]" /> | |||||
| <div class="mb-1 text-base font-medium text-[var(--c-172033)]">等待生成</div> | |||||
| <div class="text-sm text-[var(--c-a0aabb)]">上传人像并选好风格后</div> | |||||
| <div class="text-sm text-[var(--c-a0aabb)]">点击"立即生成"即可看到效果</div> | |||||
| </div> | |||||
| <!-- 处理中 --> | |||||
| <div v-if="resultState.loading" class="rounded-2xl bg-[var(--c-ffffff)] p-6 shadow-sm lg:p-8"> | |||||
| <div class="mb-1 text-base font-bold text-[var(--c-172033)]">{{ pendingTitle }}</div> | |||||
| <div class="mb-6 text-sm text-[var(--c-7f8896)]">每张单独生成,混元未购买并发,高峰期会排队</div> | |||||
| <div class="flex flex-col items-center rounded-2xl bg-[var(--c-f7f9fd)] py-10"> | |||||
| <div class="mb-4 flex gap-1.5"> | |||||
| <span class="animate-pulse-dot size-2.5 rounded-full bg-[var(--c-1f8cff)]" /> | |||||
| <span class="animate-pulse-dot2 size-2.5 rounded-full bg-[var(--c-1f8cff)]" /> | |||||
| <span class="animate-pulse-dot3 size-2.5 rounded-full bg-[var(--c-1f8cff)]" /> | |||||
| </div> | |||||
| <div class="text-sm font-bold text-[var(--c-172033)]"> | |||||
| {{ job.success_count || 0 }} / {{ job.count || quantity }} 张 · 已等待 {{ waitedSeconds }}s | |||||
| </div> | |||||
| <div v-if="pollCountdown > 0" class="mt-2 text-xs text-[var(--c-8a95a6)]">下次刷新 {{ pollCountdown }}s</div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- 失败 --> | |||||
| <div v-if="resultState.error" class="rounded-2xl bg-[var(--c-ffffff)] p-6 py-10 text-center shadow-sm lg:p-8"> | |||||
| <UIcon name="i-lucide-circle-alert" class="mx-auto mb-3 size-12 text-[var(--c-f04438)]" /> | |||||
| <div class="mb-2 text-base font-medium text-[var(--c-f04438)]">生成失败</div> | |||||
| <div class="mb-4 text-sm text-[var(--c-7f8896)]">{{ resultState.errorMsg }}</div> | |||||
| <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton> | |||||
| </div> | |||||
| <!-- 成功 --> | |||||
| <div v-if="resultState.success" class="space-y-4"> | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-sm lg:p-6"> | |||||
| <div class="mb-4 flex items-center justify-between gap-3"> | |||||
| <div class="text-base font-bold text-[var(--c-172033)]">生成结果</div> | |||||
| <span class="rounded-full bg-[var(--c-edfdf4)] px-2.5 py-1 text-[11px] font-extrabold text-[var(--c-159a54)]">{{ job.style_name }}</span> | |||||
| </div> | |||||
| <!-- 主视图直接看成品,对比放进弹层 --> | |||||
| <div class="overflow-hidden rounded-xl bg-[var(--c-f7f9fd)]"> | |||||
| <img :src="activeResultUrl" class="w-full" alt="形象照生成结果"> | |||||
| </div> | |||||
| <div v-if="resultUrls.length > 1" class="mt-3 flex flex-wrap gap-2.5"> | |||||
| <button | |||||
| v-for="(url, index) in resultUrls" | |||||
| :key="url" | |||||
| type="button" | |||||
| class="size-16 cursor-pointer overflow-hidden rounded-xl border-2 transition-colors" | |||||
| :class="activeIndex === index ? 'border-[var(--c-1f8cff)]' : 'border-transparent hover:border-[var(--c-e5edf7)]'" | |||||
| :aria-label="`查看第 ${index + 1} 张`" | |||||
| :aria-pressed="activeIndex === index" | |||||
| @click="activeIndex = index" | |||||
| > | |||||
| <img :src="url" class="size-full object-cover" :alt="`第 ${index + 1} 张`"> | |||||
| </button> | |||||
| </div> | |||||
| <div v-if="job.fail_count" class="mt-3 text-xs text-[var(--c-f04438)]"> | |||||
| 有 {{ job.fail_count }} 张生成失败,失败的不扣点 | |||||
| </div> | |||||
| <div class="mt-5 flex gap-3"> | |||||
| <button | |||||
| type="button" | |||||
| class="flex h-12 flex-1 cursor-pointer items-center justify-center rounded-full border-2 border-[var(--c-1f8cff)] font-bold text-[var(--c-1f8cff)] transition-colors hover:bg-[var(--c-eef7ff)]" | |||||
| @click="openResultCompare" | |||||
| > | |||||
| <UIcon name="i-lucide-columns-2" class="mr-1.5 size-4" /> | |||||
| 查看对比 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="flex h-12 flex-1 cursor-pointer items-center justify-center rounded-full bg-[var(--c-1f8cff)] text-base font-bold text-white shadow-[0_8px_20px_var(--sh-31-140-255-200)] transition-colors hover:bg-[var(--c-1979e6)]" | |||||
| @click="downloadResult" | |||||
| > | |||||
| <UIcon name="i-lucide-download" class="mr-1.5 size-4" /> | |||||
| 下载 | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <button | |||||
| type="button" | |||||
| class="h-12 w-full cursor-pointer rounded-full border-2 border-[var(--c-1f8cff)] font-bold text-[var(--c-1f8cff)] transition-colors hover:bg-[var(--c-eef7ff)]" | |||||
| @click="resetJob" | |||||
| > | |||||
| 再拍一组 | |||||
| </button> | |||||
| <p class="text-center text-xs leading-relaxed text-[var(--c-8a97a8)]"> | |||||
| AI 会重绘服装与背景,五官可能出现轻微变化,建议对照原图确认 | |||||
| </p> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== 移动端底部操作条 ========== --> | |||||
| <div | |||||
| class="fixed bottom-0 left-0 right-0 z-50 bg-[var(--c-ffffff)]/95 shadow-[0_-10px_30px_var(--sh-32-56-92-100)] backdrop-blur lg:hidden" | |||||
| style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))" | |||||
| > | |||||
| <div class="mb-2.5 flex items-center justify-between gap-3 text-sm"> | |||||
| <div class="min-w-0 truncate text-[var(--c-7b8797)]">{{ balanceText }}</div> | |||||
| <button v-if="isBalanceInsufficient" type="button" class="flex-shrink-0 cursor-pointer text-xs font-extrabold text-[var(--c-1f8cff)]" @click="goRecharge"> | |||||
| 去充值 | |||||
| </button> | |||||
| </div> | |||||
| <div class="flex gap-3"> | |||||
| <button type="button" class="h-12 w-[120px] cursor-pointer rounded-full bg-[var(--c-eef7ff)] text-sm font-extrabold text-[var(--c-1f8cff)]" @click="historyOpen = true"> | |||||
| 历史记录 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| class="flex h-12 flex-1 cursor-pointer items-center justify-center rounded-full bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] text-sm font-extrabold text-white shadow-[0_12px_24px_var(--sh-31-140-255-280)] transition-opacity disabled:opacity-60" | |||||
| :disabled="!canSubmit" | |||||
| @click="handleSubmit" | |||||
| > | |||||
| <UIcon v-if="!submitting" name="i-lucide-zap" class="mr-1.5 size-4" /> | |||||
| {{ submitting ? '提交中...' : submitText }} | |||||
| </button> | |||||
| </div> | |||||
| </div> | |||||
| <!-- ========== 历史记录 / 详情 ========== --> | |||||
| <WorkHistoryDrawer v-model="historyOpen" type="portrait" @select="openHistoryItem" /> | |||||
| <WorkDetailModal v-model="detailOpen" :work="currentWork" /> | |||||
| <!-- ========== 对比弹层(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-[var(--c-0f172a)]/60" @click="closeCompare" /> | |||||
| <div class="relative w-full max-w-2xl overflow-hidden rounded-2xl bg-[var(--c-ffffff)] shadow-2xl"> | |||||
| <div class="flex items-center justify-between border-b border-[var(--c-f3f4f6)] px-5 py-4"> | |||||
| <h3 class="text-base font-extrabold text-[var(--c-172033)]">{{ compareTarget === 'result' ? '生成结果对比' : '效果对比' }}</h3> | |||||
| <button | |||||
| type="button" | |||||
| class="flex size-8 cursor-pointer items-center justify-center rounded-full text-2xl leading-none text-[var(--c-a0aabb)] transition-colors hover:bg-[var(--c-f3f4f6)]" | |||||
| aria-label="关闭对比" | |||||
| @click="closeCompare" | |||||
| > | |||||
| × | |||||
| </button> | |||||
| </div> | |||||
| <div class="p-5"> | |||||
| <div | |||||
| ref="largeCompareRef" | |||||
| class="relative max-h-[70vh] w-full cursor-ew-resize select-none overflow-hidden rounded-2xl bg-[var(--c-dfe8f4)]" | |||||
| style="aspect-ratio: 3 / 4" | |||||
| @mousedown="onCompareStart" | |||||
| @touchstart.passive="onCompareStart" | |||||
| > | |||||
| <img :src="comparePair.after" class="pointer-events-none absolute inset-0 h-full w-full object-contain" alt="生成结果"> | |||||
| <img | |||||
| :src="comparePair.before" | |||||
| class="pointer-events-none absolute inset-0 h-full w-full object-contain" | |||||
| :style="{ clipPath: `inset(0 ${100 - comparePercent}% 0 0)` }" | |||||
| alt="原图" | |||||
| > | |||||
| <div | |||||
| class="pointer-events-none absolute bottom-0 top-0 -ml-px w-1 bg-[var(--c-ffffff)]/95 shadow-[0_0_12px_var(--sh-20-37-66-220)]" | |||||
| :style="{ left: comparePercent + '%' }" | |||||
| > | |||||
| <div class="absolute left-1/2 top-1/2 flex h-14 w-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center gap-px rounded-full bg-[var(--c-ffffff)] text-lg font-extrabold text-[var(--c-1f8cff)] shadow-[0_8px_18px_var(--sh-20-37-66-220)]"> | |||||
| <span>‹</span><span>›</span> | |||||
| </div> | |||||
| </div> | |||||
| <div class="pointer-events-none absolute left-3 top-3 rounded-full bg-[var(--c-0f172a)]/50 px-2 py-0.5 text-[10px] font-bold text-white">原图</div> | |||||
| <div class="pointer-events-none absolute right-3 top-3 rounded-full bg-[var(--c-0f172a)]/50 px-2 py-0.5 text-[10px] font-bold text-white">艺术照</div> | |||||
| </div> | |||||
| <div class="mt-3 text-center text-xs text-[var(--c-64748b)]">左右拖动中间按钮查看变化</div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </Transition> | |||||
| </Teleport> | |||||
| </div> | |||||
| </template> | |||||
| <script setup> | |||||
| useSeoMeta({ | |||||
| title: 'AI 形象照 / 职业照 / 写真 - AI在线', | |||||
| description: '上传一张人像照片,AI 生成商务形象照、日常职业照与古风、港风等艺术写真,支持多张生成、前后对比与原图下载。', | |||||
| }) | |||||
| const { post } = useApi() | |||||
| const { create: createPortrait, createRequestId } = usePortrait() | |||||
| const user = useUser() | |||||
| const router = useRouter() | |||||
| const toast = useToast() | |||||
| const POLL_INTERVAL = 3000 | |||||
| const POLL_LIMIT = 8 * 60 * 1000 | |||||
| // 首屏示例对比图由配置项下发(同 old_photo_demo),不在端上硬编码 | |||||
| const DEMO_CONFIG_KEY = 'portrait_photo_demo' | |||||
| // ==================== State ==================== | |||||
| const fileInput = ref(null) | |||||
| const selectedFile = ref(null) | |||||
| const previewImage = ref('') | |||||
| const extraPrompt = ref('') | |||||
| const models = ref([]) | |||||
| const styles = ref([]) | |||||
| const ratios = ref([]) | |||||
| const tips = ref([]) | |||||
| const activeModel = ref('') | |||||
| const activeStyle = ref('') | |||||
| const activeRatio = ref('') | |||||
| const options = reactive({}) | |||||
| const quantity = ref(1) | |||||
| const maxCount = ref(4) | |||||
| const unitPoints = ref(0) | |||||
| const configLoading = ref(true) | |||||
| const submitting = ref(false) | |||||
| const balance = reactive({ points: 0 }) | |||||
| const job = ref({}) | |||||
| const activeIndex = ref(0) | |||||
| const demoPair = ref({ original: '', result: '' }) | |||||
| const historyOpen = ref(false) | |||||
| const detailOpen = ref(false) | |||||
| const currentWork = ref({}) | |||||
| const resultState = ref({ visible: false, loading: false, error: false, success: false, errorMsg: '' }) | |||||
| const pollCountdown = ref(3) | |||||
| const waitedSeconds = ref(0) | |||||
| let pollTimer = null | |||||
| let countTimer = null | |||||
| let pollStart = 0 | |||||
| // ==================== Computed ==================== | |||||
| const hasDemo = computed(() => Boolean(demoPair.value.original && demoPair.value.result)) | |||||
| const currentStyle = computed(() => styles.value.find(item => item.key === activeStyle.value) || {}) | |||||
| const currentGroups = computed(() => currentStyle.value.groups || []) | |||||
| const resultUrls = computed(() => job.value.result_urls || []) | |||||
| const activeResultUrl = computed(() => resultUrls.value[activeIndex.value] || resultUrls.value[0] || '') | |||||
| const totalPoints = computed(() => unitPoints.value * quantity.value) | |||||
| const currentPoints = computed(() => Number(balance.points || 0)) | |||||
| const isBalanceInsufficient = computed( | |||||
| () => user.isLogin.value && totalPoints.value > 0 && currentPoints.value < totalPoints.value, | |||||
| ) | |||||
| const canSubmit = computed(() => Boolean(selectedFile.value) && !submitting.value && !resultState.value.loading) | |||||
| const balanceText = computed(() => { | |||||
| if (!user.isLogin.value) return unitPoints.value ? `形象照 · ${unitPoints.value}点/张` : '形象照' | |||||
| return totalPoints.value | |||||
| ? `余额${currentPoints.value}点 · 预计消耗${totalPoints.value}点` | |||||
| : `余额${currentPoints.value}点` | |||||
| }) | |||||
| const submitText = computed(() => { | |||||
| if (!user.isLogin.value) return totalPoints.value ? `登录后生成 · ${totalPoints.value}点` : '登录后生成' | |||||
| return totalPoints.value ? `立即生成 · ${totalPoints.value}点` : '立即生成' | |||||
| }) | |||||
| const pendingTitle = computed(() => (Number(job.value.status) === 10 ? '排队中,等待 AI 处理' : 'AI 正在生成形象照')) | |||||
| function showToast(message, color = 'error') { | |||||
| toast.add({ title: message, color }) | |||||
| } | |||||
| // ==================== 配置 / 示例 / 余额 ==================== | |||||
| async function loadConfig(silent = false) { | |||||
| try { | |||||
| const res = await post('/portrait/config', { | |||||
| style: activeStyle.value || undefined, | |||||
| model: activeModel.value || undefined, | |||||
| count: quantity.value, | |||||
| }) | |||||
| const data = res.data || {} | |||||
| models.value = data.models || [] | |||||
| styles.value = data.styles || [] | |||||
| ratios.value = data.ratios || [] | |||||
| tips.value = data.tips || [] | |||||
| maxCount.value = Number(data.max_count || 4) | |||||
| unitPoints.value = Number(data.unit_points || 0) | |||||
| if (!activeModel.value) activeModel.value = data.default_model || models.value[0]?.key || '' | |||||
| if (!activeRatio.value) { | |||||
| activeRatio.value = (ratios.value.find(item => item.is_default) || ratios.value[0] || {}).key || '' | |||||
| } | |||||
| if (!activeStyle.value) { | |||||
| activeStyle.value = data.default_style || styles.value[0]?.key || '' | |||||
| applyStyleDefaults() | |||||
| } | |||||
| if (data.billing?.balance) { | |||||
| balance.points = data.billing.balance.points || data.billing.balance.point_balance || 0 | |||||
| } | |||||
| } catch (e) { | |||||
| if (!silent) showToast(e.message || '配置加载失败') | |||||
| } finally { | |||||
| configLoading.value = false | |||||
| } | |||||
| } | |||||
| /** 切换风格时把该风格的选项重置为默认值 */ | |||||
| function applyStyleDefaults() { | |||||
| Object.keys(options).forEach(key => delete options[key]) | |||||
| for (const group of currentGroups.value) { | |||||
| const picked = group.items.find(item => item.is_default) || group.items[0] | |||||
| if (picked) options[group.key] = picked.key | |||||
| } | |||||
| } | |||||
| function selectStyle(key) { | |||||
| if (activeStyle.value === key) return | |||||
| activeStyle.value = key | |||||
| applyStyleDefaults() | |||||
| } | |||||
| // 换模型会影响单价,重新取一次报价 | |||||
| watch(activeModel, (value, old) => { | |||||
| if (old && value && value !== old) loadConfig(true) | |||||
| }) | |||||
| /** 示例对比图走配置项 portrait_photo_demo,格式 [{ original, result }] */ | |||||
| async function loadDemo() { | |||||
| try { | |||||
| const res = await post('/common/getconfig', { keys: DEMO_CONFIG_KEY }) | |||||
| const rows = Array.isArray(res.list) ? res.list : [] | |||||
| const item = rows.find(it => it.key === DEMO_CONFIG_KEY) | |||||
| if (!item?.value) return | |||||
| const data = typeof item.value === 'string' ? JSON.parse(item.value) : item.value | |||||
| const first = Array.isArray(data) ? data[0] : data | |||||
| if (!first) return | |||||
| demoPair.value = { | |||||
| original: first.original || first.before || first.source || '', | |||||
| result: first.result || first.after || '', | |||||
| } | |||||
| } catch (e) { | |||||
| console.error('[portrait-photo] loadDemo failed:', e) | |||||
| } | |||||
| } | |||||
| async function loadBalance() { | |||||
| try { | |||||
| const res = await post('/point/balance', {}) | |||||
| if (res.code === 0 && res.data) balance.points = res.data.points || res.data.point_balance || 0 | |||||
| } catch (e) { | |||||
| console.error('[portrait-photo] loadBalance failed:', e) | |||||
| } | |||||
| user.refreshBalance() | |||||
| } | |||||
| onMounted(() => { | |||||
| loadConfig() | |||||
| loadDemo() | |||||
| if (user.isLogin.value) loadBalance() | |||||
| }) | |||||
| onUnmounted(() => clearPoll()) | |||||
| // ==================== 上传 ==================== | |||||
| function triggerUpload() { | |||||
| if (!user.isLogin.value) { | |||||
| user.showLogin.value = true | |||||
| return | |||||
| } | |||||
| fileInput.value?.click() | |||||
| } | |||||
| function onFileChange(e) { | |||||
| const file = e.target.files?.[0] | |||||
| if (file) handleFile(file) | |||||
| e.target.value = '' | |||||
| } | |||||
| function onDrop(e) { | |||||
| if (!user.isLogin.value) { | |||||
| user.showLogin.value = true | |||||
| return | |||||
| } | |||||
| const file = e.dataTransfer?.files?.[0] | |||||
| if (file) handleFile(file) | |||||
| } | |||||
| function handleFile(file) { | |||||
| if (!/^image\//.test(file.type)) return showToast('请选择图片文件') | |||||
| if (file.size > 10 * 1024 * 1024) return showToast('图片不能超过 10M') | |||||
| if (previewImage.value) URL.revokeObjectURL(previewImage.value) | |||||
| selectedFile.value = file | |||||
| previewImage.value = URL.createObjectURL(file) | |||||
| resultState.value = { visible: false, loading: false, error: false, success: false, errorMsg: '' } | |||||
| job.value = {} | |||||
| activeIndex.value = 0 | |||||
| } | |||||
| // ==================== 提交与轮询 ==================== | |||||
| function clearPoll() { | |||||
| clearTimeout(pollTimer) | |||||
| clearInterval(countTimer) | |||||
| pollTimer = null | |||||
| countTimer = null | |||||
| } | |||||
| async function handleSubmit() { | |||||
| if (!user.isLogin.value) { | |||||
| user.showLogin.value = true | |||||
| return | |||||
| } | |||||
| if (!selectedFile.value) return showToast('请先上传人像照片') | |||||
| if (!canSubmit.value) return | |||||
| if (isBalanceInsufficient.value) return showToast('点数不足,请充值后重试') | |||||
| submitting.value = true | |||||
| resultState.value = { visible: true, loading: true, error: false, success: false, errorMsg: '' } | |||||
| waitedSeconds.value = 0 | |||||
| activeIndex.value = 0 | |||||
| pollStart = Date.now() | |||||
| try { | |||||
| const res = await createPortrait(selectedFile.value, { | |||||
| style: activeStyle.value, | |||||
| model: activeModel.value, | |||||
| ratio: activeRatio.value, | |||||
| count: quantity.value, | |||||
| extra_prompt: extraPrompt.value, | |||||
| request_id: createRequestId(), | |||||
| options: { ...options }, | |||||
| }) | |||||
| job.value = res.data || {} | |||||
| if (Number(job.value.status) === 30) { | |||||
| onSuccess() | |||||
| } else { | |||||
| startPoll() | |||||
| } | |||||
| } catch (e) { | |||||
| const msg = e.code === 1011 ? '点数不足,请充值后重试' : (e.message || '提交失败,请稍后重试') | |||||
| resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: msg } | |||||
| } finally { | |||||
| submitting.value = false | |||||
| } | |||||
| } | |||||
| function startPoll() { | |||||
| clearPoll() | |||||
| pollCountdown.value = POLL_INTERVAL / 1000 | |||||
| countTimer = setInterval(() => { | |||||
| pollCountdown.value = Math.max(0, pollCountdown.value - 1) | |||||
| waitedSeconds.value = Math.round((Date.now() - pollStart) / 1000) | |||||
| }, 1000) | |||||
| pollTimer = setTimeout(async () => { | |||||
| if (Date.now() - pollStart > POLL_LIMIT) { | |||||
| clearPoll() | |||||
| resultState.value = { | |||||
| visible: true, | |||||
| loading: false, | |||||
| error: true, | |||||
| success: false, | |||||
| errorMsg: '生成时间较长,可稍后从历史记录查看结果', | |||||
| } | |||||
| return | |||||
| } | |||||
| try { | |||||
| const res = await post('/portrait/getdetail', { photo_open_id: job.value.photo_open_id }) | |||||
| const data = res.data || {} | |||||
| job.value = data | |||||
| const status = Number(data.status) | |||||
| if (status === 30) { | |||||
| clearPoll() | |||||
| onSuccess() | |||||
| } else if (status < 0) { | |||||
| clearPoll() | |||||
| resultState.value = { | |||||
| visible: true, | |||||
| loading: false, | |||||
| error: true, | |||||
| success: false, | |||||
| errorMsg: data.error_msg || '生成失败,本次未扣点', | |||||
| } | |||||
| } else { | |||||
| startPoll() | |||||
| } | |||||
| } catch (e) { | |||||
| // 单次查询失败不打断轮询 | |||||
| startPoll() | |||||
| } | |||||
| }, POLL_INTERVAL) | |||||
| } | |||||
| function onSuccess() { | |||||
| activeIndex.value = 0 | |||||
| resultState.value = { visible: true, loading: false, error: false, success: true, errorMsg: '' } | |||||
| loadBalance() | |||||
| } | |||||
| function resetJob() { | |||||
| clearPoll() | |||||
| job.value = {} | |||||
| activeIndex.value = 0 | |||||
| selectedFile.value = null | |||||
| if (previewImage.value) URL.revokeObjectURL(previewImage.value) | |||||
| previewImage.value = '' | |||||
| extraPrompt.value = '' | |||||
| resultState.value = { visible: false, loading: false, error: false, success: false, errorMsg: '' } | |||||
| } | |||||
| async function downloadResult() { | |||||
| const url = activeResultUrl.value | |||||
| if (!url) return | |||||
| try { | |||||
| const res = await fetch(url) | |||||
| const blob = await res.blob() | |||||
| const link = document.createElement('a') | |||||
| link.href = URL.createObjectURL(blob) | |||||
| link.download = `portrait-photo-${Date.now()}.jpg` | |||||
| link.click() | |||||
| URL.revokeObjectURL(link.href) | |||||
| } catch { | |||||
| window.open(url, '_blank') | |||||
| } | |||||
| } | |||||
| function goRecharge() { | |||||
| router.push('/recharge') | |||||
| } | |||||
| function openHistoryItem(item) { | |||||
| currentWork.value = item | |||||
| detailOpen.value = true | |||||
| } | |||||
| // ==================== 对比弹层 ==================== | |||||
| const compareVisible = ref(false) | |||||
| const comparePercent = ref(50) | |||||
| const compareTarget = ref('demo') | |||||
| const largeCompareRef = ref(null) | |||||
| let dragging = false | |||||
| const comparePair = computed(() => { | |||||
| if (compareTarget.value === 'result') { | |||||
| return { before: job.value.source_url || previewImage.value, after: activeResultUrl.value } | |||||
| } | |||||
| return { before: demoPair.value.original, after: demoPair.value.result } | |||||
| }) | |||||
| function openDemoCompare() { | |||||
| if (!hasDemo.value) return showToast('暂无对比图') | |||||
| comparePercent.value = 50 | |||||
| compareTarget.value = 'demo' | |||||
| compareVisible.value = true | |||||
| } | |||||
| function openResultCompare() { | |||||
| if (!activeResultUrl.value) return showToast('结果图暂不可用') | |||||
| comparePercent.value = 50 | |||||
| compareTarget.value = 'result' | |||||
| compareVisible.value = true | |||||
| } | |||||
| function closeCompare() { | |||||
| compareVisible.value = false | |||||
| onCompareEnd() | |||||
| } | |||||
| function onCompareStart(e) { | |||||
| dragging = true | |||||
| updateComparePercent(e.touches?.[0] || 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 percent = (((e.clientX || 0) - rect.left) / rect.width) * 100 | |||||
| comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1)))) | |||||
| } | |||||
| </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; } | |||||
| /* 尊重系统的减少动效设置 */ | |||||
| @media (prefers-reduced-motion: reduce) { | |||||
| .animate-pulse-dot, | |||||
| .animate-pulse-dot2, | |||||
| .animate-pulse-dot3 { animation: none; } | |||||
| .modal-enter-active, .modal-leave-active { transition: none; } | |||||
| } | |||||
| </style> | |||||
| @@ -0,0 +1,14 @@ | |||||
| <template> | |||||
| <LegalDocument doc-key="privacy" fallback-title="隐私政策" /> | |||||
| </template> | |||||
| <script setup> | |||||
| // 正文由后台「内容管理」维护(html 表 key=privacy) | |||||
| useSeoMeta({ | |||||
| title: '隐私政策 - AI在线', | |||||
| description: 'AI在线隐私政策,说明我们收集、使用、存储与保护用户个人信息的方式与范围。', | |||||
| }) | |||||
| useHead({ | |||||
| link: [{ rel: 'canonical', href: 'https://tool.aionline.cc/privacy' }], | |||||
| }) | |||||
| </script> | |||||
| @@ -1,108 +1,172 @@ | |||||
| <template> | <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> | |||||
| <AppHero | |||||
| kicker="AI ONLINE POINTS" | |||||
| title="点数充值" | |||||
| desc="点数长期有效,多端通用。小程序、H5、PC 端共享同一份点数。" | |||||
| > | |||||
| <template #aside> | |||||
| <!-- 余额卡 --> | |||||
| <div class="w-full rounded-3xl border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/80 p-5 shadow-[0_18px_34px_var(--sh-0-92-190-100)] backdrop-blur lg:w-[260px]"> | |||||
| <div class="text-xs font-bold text-[var(--c-8a97a8)]">我的点数</div> | |||||
| <div class="mt-2 flex items-end gap-1.5"> | |||||
| <span class="text-4xl font-extrabold leading-none text-[var(--c-0b8cff)]">{{ balanceText }}</span> | |||||
| <span v-if="balanceUnit" class="pb-1 text-sm font-bold text-[var(--c-8a97a8)]">{{ balanceUnit }}</span> | |||||
| </div> | |||||
| <NuxtLink to="/bill" class="mt-3 inline-flex items-center gap-1 text-xs font-bold text-[var(--c-0b8cff)] hover:underline"> | |||||
| 查看账单明细 | |||||
| <UIcon name="i-lucide-chevron-right" class="size-3.5" /> | |||||
| </NuxtLink> | |||||
| </div> | |||||
| </template> | |||||
| </AppHero> | |||||
| <!-- 余额 --> | |||||
| <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="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8"> | |||||
| <div class="lg:grid lg:grid-cols-12 lg:gap-8"> | |||||
| <!-- ===== 左:套餐选择 ===== --> | |||||
| <div class="lg:col-span-7"> | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_8px_28px_var(--sh-34-68-112-50)] lg:p-6"> | |||||
| <div class="flex items-baseline"> | |||||
| <h2 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">选择充值套餐</h2> | |||||
| <span class="ml-auto text-xs text-[var(--c-8a95a6)]">点数长期有效</span> | |||||
| </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 v-if="loading" class="grid grid-cols-2 gap-3 pt-4 sm:grid-cols-3"> | |||||
| <div v-for="i in 3" :key="i" class="h-[124px] animate-pulse rounded-2xl bg-[var(--c-f1f5fa)]" /> | |||||
| </div> | |||||
| <!-- 套餐网格 --> | |||||
| <div v-else-if="packages.length" class="grid grid-cols-2 gap-3 pt-4 sm:grid-cols-3"> | |||||
| <button | |||||
| v-for="pkg in packages" | |||||
| :key="pkg.id" | |||||
| type="button" | |||||
| class="relative flex min-h-[124px] flex-col items-center justify-center gap-2 rounded-2xl border-2 p-4 transition-all" | |||||
| :class="current && current.id === pkg.id | |||||
| ? 'border-[var(--c-0b8cff)] bg-[var(--c-f4faff)] shadow-[0_0_0_4px_var(--sh-11-140-255-100)]' | |||||
| : 'border-transparent bg-[var(--c-f7f9fd)] hover:border-[var(--c-e5edf7)]'" | |||||
| @click="selectPackage(pkg)" | |||||
| > | |||||
| <span | |||||
| v-if="pkg.badge" | |||||
| class="absolute left-1/2 top-2 -translate-x-1/2 whitespace-nowrap rounded-full bg-[var(--c-0b8cff)]/10 px-2 py-0.5 text-[10px] font-extrabold text-[var(--c-0b8cff)]" | |||||
| >{{ pkg.badge }}</span> | |||||
| <span class="mt-4 text-xl font-extrabold text-[var(--c-172033)]">{{ pkg.points }}点</span> | |||||
| <span class="text-sm font-extrabold text-[var(--c-0b8cff)]">¥{{ pkg.price_yuan }}</span> | |||||
| <span v-if="pkg.old_price_yuan" class="flex items-center gap-1.5 text-xs"> | |||||
| <span class="text-[var(--c-a0aabb)] line-through">¥{{ pkg.old_price_yuan }}</span> | |||||
| <span | |||||
| v-if="pkg.bonus_percent" | |||||
| class="rounded-full bg-[var(--c-edfdf4)] px-1.5 py-0.5 font-extrabold text-[var(--c-159a54)]" | |||||
| >多送{{ pkg.bonus_percent }}%</span> | |||||
| </span> | |||||
| </button> | |||||
| </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 v-else class="py-14 text-center"> | |||||
| <UIcon name="i-lucide-package-x" class="mx-auto mb-3 size-10 text-[var(--c-a0aabb)]" /> | |||||
| <div class="text-sm text-[var(--c-8a95a6)]">暂无可用套餐</div> | |||||
| </div> | </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> | |||||
| <!-- 确认充值 --> | |||||
| <button | |||||
| type="button" | |||||
| class="mt-5 flex h-12 w-full items-center justify-center rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-3aa9ff)] text-base font-extrabold text-white shadow-[0_12px_24px_var(--sh-11-140-255-220)] transition-opacity disabled:cursor-not-allowed disabled:opacity-50 lg:h-14" | |||||
| :disabled="!current || payState === 'loading'" | |||||
| @click="startPay" | |||||
| > | |||||
| {{ buyText }} | |||||
| </button> | |||||
| </div> | |||||
| <!-- 权益 --> | |||||
| <div class="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2"> | |||||
| <div class="rounded-2xl bg-[var(--c-0b8cff)]/[0.07] p-4"> | |||||
| <div class="text-sm font-extrabold text-[var(--c-0b8cff)]">点数长期有效</div> | |||||
| <div class="mt-1.5 text-xs leading-relaxed text-[var(--c-66758a)]">购买后可用于所有 AI 图像工具</div> | |||||
| </div> | |||||
| <div class="rounded-2xl bg-[var(--c-0b8cff)]/[0.07] p-4"> | |||||
| <div class="text-sm font-extrabold text-[var(--c-0b8cff)]">多端通用</div> | |||||
| <div class="mt-1.5 text-xs leading-relaxed text-[var(--c-66758a)]">小程序、H5、PC 端共享点数</div> | |||||
| </div> | </div> | ||||
| </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 class="mt-5 rounded-2xl bg-[var(--c-ffffff)] p-5 text-sm leading-relaxed text-[var(--c-8a95a6)] shadow-[0_8px_28px_var(--sh-34-68-112-40)]"> | |||||
| <div class="mb-1 font-bold text-[var(--c-7f8896)]">购买须知:</div> | |||||
| <div>1、购买后长期有效,不支持退款;</div> | |||||
| <div>2、同一账号点数可在小程序、H5、PC端使用;</div> | |||||
| <div>3、点数适用于 AI 图片生成、高清修复、证件照、AI绘画等功能。</div> | |||||
| </div> | |||||
| </div> | </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 class="mt-5 lg:col-span-5 lg:mt-0"> | |||||
| <div class="space-y-4 lg:sticky lg:top-20"> | |||||
| <div class="rounded-2xl bg-[var(--c-ffffff)] p-6 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]"> | |||||
| <!-- 未开始 --> | |||||
| <template v-if="payState === 'idle'"> | |||||
| <UIcon name="i-lucide-qr-code" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" /> | |||||
| <div class="mb-1 text-base font-bold text-[var(--c-172033)]">微信扫码支付</div> | |||||
| <div class="text-sm text-[var(--c-a0aabb)]">选择套餐后点击「确认充值」</div> | |||||
| <div class="text-sm text-[var(--c-a0aabb)]">这里会生成支付二维码</div> | |||||
| </template> | |||||
| <!-- 生成中 / 待支付 / 成功 --> | |||||
| <template v-else> | |||||
| <div class="mb-1 text-base font-bold text-[var(--c-172033)]"> | |||||
| {{ payState === 'success' ? '充值成功' : '微信扫码支付' }} | |||||
| </div> | |||||
| <div class="mb-4 text-sm text-[var(--c-8a95a6)]"> | |||||
| {{ payStateText }} | |||||
| </div> | |||||
| <div class="mx-auto mb-4 flex h-[200px] w-[200px] items-center justify-center rounded-2xl bg-[var(--c-f7f9fd)]"> | |||||
| <div v-if="payState === 'loading'" class="flex flex-col items-center gap-2"> | |||||
| <span class="size-7 animate-spin rounded-full border-2 border-[var(--c-0b8cff)] border-t-transparent" /> | |||||
| <span class="text-xs text-[var(--c-a0aabb)]">生成二维码...</span> | |||||
| </div> | |||||
| <div v-else-if="payState === 'success'" class="flex flex-col items-center gap-2"> | |||||
| <UIcon name="i-lucide-circle-check" class="size-14 text-[var(--c-17a65a)]" /> | |||||
| <span class="text-sm font-bold text-[var(--c-17a65a)]">+{{ current?.points || 0 }} 点已到账</span> | |||||
| </div> | |||||
| <ClientOnly v-else-if="codeUrl"> | |||||
| <vue-qr :text="codeUrl" :size="180" :margin="8" /> | |||||
| </ClientOnly> | |||||
| <div v-else class="flex flex-col items-center gap-2"> | |||||
| <UIcon name="i-lucide-triangle-alert" class="size-9 text-[var(--c-f04438)]" /> | |||||
| <span class="text-xs text-[var(--c-f04438)]">生成失败,请重试</span> | |||||
| </div> | |||||
| </div> | |||||
| <div v-if="payState !== 'success'" class="text-xs text-[var(--c-a0aabb)]"> | |||||
| 支付金额:¥{{ current?.price_yuan || '0.00' }} | |||||
| </div> | |||||
| <div class="mt-4 flex justify-center gap-3"> | |||||
| <button | |||||
| v-if="payState !== 'success'" | |||||
| class="rounded-full px-4 py-2 text-xs text-[var(--c-8a95a6)] transition-colors hover:text-[var(--c-172033)]" | |||||
| @click="cancelPay" | |||||
| > | |||||
| 取消支付 | |||||
| </button> | |||||
| <button | |||||
| v-else | |||||
| class="rounded-full bg-[var(--c-0b8cff)] px-6 py-2 text-xs font-bold text-white transition-colors hover:bg-[var(--c-0a7ce0)]" | |||||
| @click="finishPay" | |||||
| > | |||||
| 完成 | |||||
| </button> | |||||
| </div> | |||||
| </template> | |||||
| </div> | |||||
| </div> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -113,130 +177,151 @@ | |||||
| import VueQr from 'vue-qr' | import VueQr from 'vue-qr' | ||||
| useSeoMeta({ | useSeoMeta({ | ||||
| title: '充值中心 - 奇想宇宙', | |||||
| description: '选择套餐,为AI创作充值点数', | |||||
| title: '点数充值 - AI在线', | |||||
| description: '选择套餐充值点数,点数长期有效、多端通用', | |||||
| }) | }) | ||||
| const { userInfo, isLogin } = useUser() | |||||
| const { userInfo, isLogin, showLogin, refreshBalance } = useUser() | |||||
| const { post } = useApi() | const { post } = useApi() | ||||
| const toast = useToast() | |||||
| // 套餐列表 | |||||
| const products = ref([]) | |||||
| const selectedId = ref(null) | |||||
| const selectedPrice = ref(0) | |||||
| const packages = ref([]) | |||||
| const current = ref(null) | |||||
| const loading = ref(true) | |||||
| const balance = reactive({ points: 0 }) | |||||
| // 支付状态 | |||||
| const isPaying = ref(false) | |||||
| const payState = ref('idle') // idle | loading | pending | success | error | |||||
| // idle | loading | pending | success | error | |||||
| const payState = ref('idle') | |||||
| const codeUrl = ref('') | const codeUrl = ref('') | ||||
| const orderNo = ref('') | const orderNo = ref('') | ||||
| let pollingTimer = null | let pollingTimer = null | ||||
| // 获取套餐列表 | |||||
| async function loadProducts() { | |||||
| const balanceText = computed(() => (Number(balance.points) < 0 ? '不限' : String(Number(balance.points) || 0))) | |||||
| const balanceUnit = computed(() => (balanceText.value === '不限' ? '' : '点')) | |||||
| const buyText = computed(() => { | |||||
| if (!current.value) return '请选择套餐' | |||||
| return `确认充值(${current.value.price_yuan}元)` | |||||
| }) | |||||
| const payStateText = computed(() => { | |||||
| if (payState.value === 'loading') return '正在创建订单...' | |||||
| if (payState.value === 'pending') return '请使用微信扫描二维码完成支付' | |||||
| if (payState.value === 'success') return '点数已到账,可以开始创作了' | |||||
| return '订单创建失败' | |||||
| }) | |||||
| function showMsg(msg, color = 'error') { | |||||
| toast.add({ title: msg, color }) | |||||
| } | |||||
| // ==================== 套餐 & 余额 ==================== | |||||
| async function loadPackages() { | |||||
| loading.value = true | |||||
| try { | 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, | |||||
| })) | |||||
| } | |||||
| const res = await post('/point/packagelist') | |||||
| packages.value = res.list || [] | |||||
| current.value = packages.value.find(p => p.badge === '推荐') || packages.value[0] || null | |||||
| } catch (e) { | |||||
| packages.value = [] | |||||
| showMsg(e.message || '套餐加载失败') | |||||
| } finally { | |||||
| loading.value = false | |||||
| } | |||||
| } | |||||
| async function loadBalance() { | |||||
| if (!isLogin.value) return | |||||
| try { | |||||
| const res = await post('/point/balance') | |||||
| balance.points = res.data?.points || 0 | |||||
| userInfo.value = { ...userInfo.value, balance: balance.points } | |||||
| } catch { | } catch { | ||||
| products.value = [] | |||||
| // 忽略 | |||||
| } | } | ||||
| } | } | ||||
| // 选择套餐 | |||||
| function selectProduct(pkg) { | |||||
| selectedId.value = pkg.id | |||||
| selectedPrice.value = pkg.price | |||||
| function selectPackage(pkg) { | |||||
| if (payState.value === 'pending' || payState.value === 'loading') cancelPay() | |||||
| current.value = pkg | |||||
| } | } | ||||
| // 开始支付(对齐老 PC 的 buy 流程) | |||||
| // ==================== 支付(PC 走微信 NATIVE 扫码) ==================== | |||||
| async function startPay() { | async function startPay() { | ||||
| if (!selectedId.value) return | |||||
| if (!current.value) return | |||||
| if (!isLogin.value) { | |||||
| showLogin.value = true | |||||
| return | |||||
| } | |||||
| payState.value = 'loading' | payState.value = 'loading' | ||||
| isPaying.value = true | |||||
| codeUrl.value = '' | |||||
| try { | try { | ||||
| const res = await post('/vip/createorder', { | |||||
| product_id: selectedId.value, | |||||
| client: 1, | |||||
| const res = await post('/point/createorder', { | |||||
| package_id: current.value.id, | |||||
| client: 1, // PC | |||||
| }) | }) | ||||
| if (res.code === 0 && res.data?.code_url) { | |||||
| codeUrl.value = res.data.code_url | |||||
| orderNo.value = res.data.order_no || '' | |||||
| const data = res.data || {} | |||||
| // NATIVE 支付返回二维码链接(兼容多种字段命名) | |||||
| const url = data.code_url || data.codeUrl || data.qr_code || '' | |||||
| orderNo.value = data.order_no || '' | |||||
| if (url) { | |||||
| codeUrl.value = url | |||||
| payState.value = 'pending' | payState.value = 'pending' | ||||
| startPolling() | startPolling() | ||||
| } else { | } else { | ||||
| payState.value = 'error' | payState.value = 'error' | ||||
| showMsg(res.msg || 'PC 端支付暂不可用') | |||||
| } | } | ||||
| } catch { | |||||
| } catch (e) { | |||||
| payState.value = 'error' | payState.value = 'error' | ||||
| showMsg(e.message || '创建订单失败') | |||||
| } | } | ||||
| } | } | ||||
| // 轮询支付结果(对齐老 PC:每 2 秒轮询 paysuccess) | |||||
| // 轮询订单状态:服务端会主动查微信并流转订单 + 幂等入账 | |||||
| function startPolling() { | function startPolling() { | ||||
| clearInterval(pollingTimer) | clearInterval(pollingTimer) | ||||
| pollingTimer = setInterval(async () => { | pollingTimer = setInterval(async () => { | ||||
| if (!orderNo.value) return | |||||
| try { | 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)) { | |||||
| const res = await post('/point/orderstatus', { order_no: orderNo.value }) | |||||
| const d = res.data || {} | |||||
| if (Number(d.status) === 20) { | |||||
| clearInterval(pollingTimer) | clearInterval(pollingTimer) | ||||
| payState.value = 'success' | payState.value = 'success' | ||||
| // 刷新余额 | |||||
| await refreshUserInfo() | |||||
| balance.points = d.balance ?? balance.points | |||||
| refreshBalance() // 同步 header 点数 | |||||
| } | } | ||||
| } catch { | } catch { | ||||
| // 轮询失败不中断 | |||||
| // 单次失败不中断轮询 | |||||
| } | } | ||||
| }, 2000) | }, 2000) | ||||
| } | } | ||||
| // 取消支付 | |||||
| function cancelPay() { | function cancelPay() { | ||||
| clearInterval(pollingTimer) | clearInterval(pollingTimer) | ||||
| isPaying.value = false | |||||
| payState.value = 'idle' | payState.value = 'idle' | ||||
| codeUrl.value = '' | codeUrl.value = '' | ||||
| orderNo.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 { | |||||
| // 忽略 | |||||
| } | |||||
| function finishPay() { | |||||
| cancelPay() | |||||
| loadBalance() | |||||
| } | } | ||||
| onMounted(() => { | onMounted(() => { | ||||
| loadProducts() | |||||
| if (isLogin.value) { | |||||
| refreshUserInfo() | |||||
| } | |||||
| loadPackages() | |||||
| loadBalance() | |||||
| }) | }) | ||||
| onUnmounted(() => { | |||||
| clearInterval(pollingTimer) | |||||
| watch(isLogin, async (loggedIn) => { | |||||
| if (!loggedIn) { | |||||
| cancelPay() | |||||
| balance.points = 0 | |||||
| } | |||||
| await loadPackages() | |||||
| if (loggedIn) await loadBalance() | |||||
| }) | }) | ||||
| onUnmounted(() => clearInterval(pollingTimer)) | |||||
| </script> | </script> | ||||
| @@ -0,0 +1,14 @@ | |||||
| <template> | |||||
| <LegalDocument doc-key="service" fallback-title="服务条款" /> | |||||
| </template> | |||||
| <script setup> | |||||
| // 正文由后台「内容管理」维护(html 表 key=service),SSR 渲染,利于收录 | |||||
| useSeoMeta({ | |||||
| title: '服务条款 - AI在线', | |||||
| description: 'AI在线服务条款,说明用户在使用 AI 取名、动漫头像、证件照等服务时的权利与义务。', | |||||
| }) | |||||
| useHead({ | |||||
| link: [{ rel: 'canonical', href: 'https://tool.aionline.cc/terms' }], | |||||
| }) | |||||
| </script> | |||||
| @@ -1,160 +1,288 @@ | |||||
| <template> | <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> | |||||
| <AppHero | |||||
| kicker="MY CREATIONS" | |||||
| title="我的作品" | |||||
| desc="AI 取名、动漫头像、证件照等全部创作记录,生成中的作品会自动刷新。" | |||||
| /> | |||||
| <!-- 加载中 --> | |||||
| <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 class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8"> | |||||
| <!-- 未登录 --> | |||||
| <div v-if="!isLogin" class="rounded-2xl bg-[var(--c-ffffff)] py-20 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]"> | |||||
| <UIcon name="i-lucide-image" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" /> | |||||
| <div class="text-base font-extrabold text-[var(--c-172033)]">登录后查看作品</div> | |||||
| <div class="mx-auto mt-2 max-w-sm text-sm text-[var(--c-8a95a6)]">登录后会同步你的作品、点数和创作记录。</div> | |||||
| <button | |||||
| class="mt-5 h-10 rounded-full bg-[var(--c-0b8cff)] px-7 text-sm font-extrabold text-white transition-colors hover:bg-[var(--c-0a7ce0)]" | |||||
| @click="showLogin = true" | |||||
| > | |||||
| 去登录 | |||||
| </button> | |||||
| </div> | |||||
| <template v-else> | |||||
| <!-- 分类 Tabs(吸顶) --> | |||||
| <div class="sticky top-16 z-20 -mx-4 mb-5 flex gap-2.5 overflow-x-auto border-b border-[var(--c-eef1f6)] bg-[var(--c-f3f7fb)]/95 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8"> | |||||
| <button | |||||
| v-for="item in tabs" | |||||
| :key="item.key" | |||||
| type="button" | |||||
| class="flex h-9 shrink-0 items-center gap-2 rounded-full px-4 transition-colors" | |||||
| :class="activeTab === item.key ? 'bg-[var(--c-eaf5ff)] text-[var(--c-0b8cff)]' : 'bg-[var(--c-ffffff)] text-[var(--c-687386)] hover:bg-[var(--c-f4f7fb)]'" | |||||
| @click="changeTab(item.key)" | |||||
| > | |||||
| <span class="text-sm font-extrabold">{{ item.name }}</span> | |||||
| <span | |||||
| class="min-w-[20px] rounded-full px-1.5 py-0.5 text-center text-[10px] font-extrabold" | |||||
| :class="activeTab === item.key ? 'bg-[var(--c-ffffff)] text-[var(--c-0b8cff)]' : 'bg-[var(--c-f4f7fb)] text-[var(--c-8a95a8)]'" | |||||
| >{{ counts[item.key] || 0 }}</span> | |||||
| </button> | |||||
| </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-if="list.length" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> | |||||
| <div | <div | ||||
| v-else-if="work.status === 'processing'" | |||||
| class="w-full h-full flex items-center justify-center bg-indigo-50" | |||||
| v-for="item in list" | |||||
| :key="item.id" | |||||
| class="group cursor-pointer overflow-hidden rounded-2xl bg-[var(--c-ffffff)] shadow-[0_10px_28px_var(--sh-34-68-112-60)] transition-shadow hover:shadow-[0_14px_34px_var(--sh-34-68-112-120)]" | |||||
| @click="openWork(item)" | |||||
| > | > | ||||
| <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 || '作品' }} | |||||
| <!-- AI 取名卡片 --> | |||||
| <div | |||||
| v-if="item.work_type === 'ainame'" | |||||
| class="min-h-[190px] bg-gradient-to-b from-[var(--c-f8fbff)] to-[var(--c-ffffff)] p-3.5" | |||||
| > | |||||
| <div class="flex items-center justify-between gap-2"> | |||||
| <span class="rounded-full bg-[var(--c-eaf5ff)] px-2 py-0.5 text-[10px] font-extrabold text-[var(--c-0b8cff)]">AI取名</span> | |||||
| <span class="rounded-full px-2 py-0.5 text-[10px] font-extrabold" :class="statusClass(item)"> | |||||
| {{ item.status_text }} | |||||
| </span> | |||||
| </div> | |||||
| <div class="mt-3 flex min-h-[76px] flex-wrap content-start gap-1.5"> | |||||
| <span | |||||
| v-for="name in item.names" | |||||
| :key="name" | |||||
| class="rounded-full bg-[var(--c-eaf5ff)] px-2.5 py-1 text-xs font-extrabold text-[var(--c-0b8cff)]" | |||||
| >{{ name }}</span> | |||||
| <span v-if="!item.names || !item.names.length" class="text-xs text-[var(--c-8a95a8)]">名字整理中</span> | |||||
| </div> | |||||
| <div class="mt-1 line-clamp-2 text-[11px] leading-relaxed text-[var(--c-687386)]"> | |||||
| {{ item.summary || item.subtitle }} | |||||
| </div> | |||||
| </div> | |||||
| <!-- 图片类作品 --> | |||||
| <div v-else class="relative aspect-square overflow-hidden bg-[var(--c-eef3f8)]"> | |||||
| <img | |||||
| v-if="item.cover" | |||||
| :src="item.cover" | |||||
| class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" | |||||
| :alt="typeText(item.work_type)" | |||||
| loading="lazy" | |||||
| > | |||||
| <div v-else class="flex h-full w-full flex-col items-center justify-center gap-2 text-xs font-bold text-[var(--c-8a95a8)]"> | |||||
| <span v-if="Number(item.status) === 0" class="size-8 animate-spin rounded-full border-[3px] border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)]" /> | |||||
| <span>{{ Number(item.status) === 0 ? '生成中' : '暂无封面' }}</span> | |||||
| </div> | |||||
| <!-- 顶部渐变 + 标签 --> | |||||
| <div class="pointer-events-none absolute inset-x-0 top-0 h-14 bg-gradient-to-b from-black/35 to-transparent" /> | |||||
| <div class="absolute inset-x-3 top-3 flex items-center justify-between gap-2"> | |||||
| <span class="rounded-full bg-[var(--c-ffffff)]/90 px-2 py-0.5 text-[10px] font-extrabold text-[var(--c-172033)]"> | |||||
| {{ typeText(item.work_type) }} | |||||
| </span> | |||||
| <span class="rounded-full px-2 py-0.5 text-[10px] font-extrabold" :class="statusClass(item)"> | |||||
| {{ item.status_text }} | |||||
| </span> | |||||
| </div> | |||||
| <!-- 生成中遮罩 --> | |||||
| <div | |||||
| v-if="Number(item.status) === 0" | |||||
| class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-xs font-bold text-white" | |||||
| style="background-color: var(--sh-9-16-28-420)" | |||||
| > | |||||
| <span class="size-8 animate-spin rounded-full border-[3px] border-white/35 border-t-white" /> | |||||
| <span>正在创作</span> | |||||
| </div> | |||||
| </div> | |||||
| <div class="px-3.5 py-3"> | |||||
| <div class="text-[11px] text-[var(--c-a0a8b8)]">{{ formatTime(item.add_time) }}</div> | |||||
| </div> | |||||
| </div> | </div> | ||||
| </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 v-if="!list.length && !loading" class="rounded-2xl bg-[var(--c-ffffff)] py-20 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]"> | |||||
| <div class="mx-auto mb-5 grid size-14 grid-cols-2 gap-1.5 rounded-2xl bg-[var(--c-ffffff)] p-2.5 shadow-[0_10px_28px_var(--sh-34-68-112-60)]"> | |||||
| <span class="rounded-md bg-[var(--c-dceeff)]" /> | |||||
| <span class="rounded-md bg-[var(--c-b9ddff)]" /> | |||||
| <span class="rounded-md bg-[var(--c-b9ddff)]" /> | |||||
| <span class="rounded-md bg-[var(--c-dceeff)]" /> | |||||
| </div> | |||||
| <div class="text-base font-extrabold text-[var(--c-172033)]">还没有作品</div> | |||||
| <div class="mx-auto mt-2 max-w-sm text-sm text-[var(--c-7f8896)]">去工具箱生成第一张头像、照片或名字方案。</div> | |||||
| <NuxtLink | |||||
| to="/" | |||||
| class="mt-5 inline-flex h-10 items-center rounded-full bg-[var(--c-0b8cff)] px-7 text-sm font-extrabold text-white transition-colors hover:bg-[var(--c-0a7ce0)]" | |||||
| > | |||||
| 去创作 | |||||
| </NuxtLink> | |||||
| </div> | </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 v-if="loading" class="py-8 text-center text-sm text-[var(--c-9aa4b2)]"> | |||||
| {{ list.length ? '加载更多...' : '加载中...' }} | |||||
| </div> | |||||
| <div v-else-if="hasMore" class="mt-8 flex justify-center"> | |||||
| <button | |||||
| class="rounded-full bg-[var(--c-eef7ff)] px-7 py-2.5 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)]" | |||||
| @click="loadMore" | |||||
| > | |||||
| 加载更多 | |||||
| </button> | |||||
| </div> | |||||
| <div v-else-if="list.length" class="py-8 text-center text-sm text-[var(--c-9aa4b2)]">没有更多了</div> | |||||
| </template> | |||||
| </div> | </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> | |||||
| <!-- 作品详情弹窗 --> | |||||
| <WorkDetailModal v-model="detailOpen" :work="currentWork" /> | |||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| <script setup> | <script setup> | ||||
| import dayjs from 'dayjs' | |||||
| useSeoMeta({ | useSeoMeta({ | ||||
| title: '我的作品 - 奇想宇宙', | |||||
| description: '查看AI创作的所有作品', | |||||
| title: '我的作品 - AI在线', | |||||
| description: '查看 AI 取名、动漫头像、证件照等全部创作记录', | |||||
| }) | }) | ||||
| const { isLogin } = useUser() | |||||
| const { isLogin, showLogin } = useUser() | |||||
| const { post } = useApi() | const { post } = useApi() | ||||
| const toast = useToast() | |||||
| const tabs = [ | const tabs = [ | ||||
| { label: '全部', key: 'all' }, | |||||
| { label: 'AI取名', key: 'name' }, | |||||
| { label: '动漫头像', key: 'avatar' }, | |||||
| { label: 'AI绘画', key: 'draw' }, | |||||
| { key: 'all', name: '全部' }, | |||||
| { key: 'ainame', name: 'AI取名' }, | |||||
| { key: 'anime', name: '动漫头像' }, | |||||
| { key: 'photo', name: '证件照' }, | |||||
| { key: 'oldphoto', name: '老照片' }, | |||||
| { key: 'portrait', name: '形象照' }, | |||||
| ] | ] | ||||
| const activeTab = ref('all') | const activeTab = ref('all') | ||||
| const loading = ref(true) | |||||
| const works = ref([]) | |||||
| const list = ref([]) | |||||
| const total = ref(0) | |||||
| const page = ref(1) | const page = ref(1) | ||||
| const hasMore = ref(false) | |||||
| const pageSize = 20 | |||||
| const loading = ref(false) | |||||
| const counts = ref({ all: 0, ainame: 0, anime: 0, image: 0, photo: 0, oldphoto: 0, portrait: 0 }) | |||||
| let timer = null | |||||
| const hasMore = computed(() => list.value.length < total.value) | |||||
| // 格式化日期 | |||||
| function formatDate(dateStr) { | |||||
| if (!dateStr) return '' | |||||
| return String(dateStr).substring(0, 10) | |||||
| function formatTime(value) { | |||||
| if (!value) return '' | |||||
| return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm') | |||||
| } | } | ||||
| // 加载作品 | |||||
| async function loadWorks(reset = false) { | |||||
| if (reset) { | |||||
| page.value = 1 | |||||
| works.value = [] | |||||
| async function fetchList(reset = false, silent = false) { | |||||
| if (!isLogin.value) { | |||||
| list.value = [] | |||||
| total.value = 0 | |||||
| return | |||||
| } | } | ||||
| if (loading.value) return | |||||
| loading.value = true | loading.value = true | ||||
| if (reset) page.value = 1 | |||||
| try { | try { | ||||
| const res = await post('/tool/getworks', { | |||||
| type: activeTab.value === 'all' ? '' : activeTab.value, | |||||
| page: page.value, | |||||
| page_size: 20, | |||||
| client: 1, | |||||
| const res = await post('/work/getlist', { | |||||
| type: activeTab.value, | |||||
| page_no: page.value, | |||||
| page_size: pageSize, | |||||
| }) | }) | ||||
| 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 { | |||||
| // 忽略 | |||||
| const rows = res.list || [] | |||||
| const data = res.data || {} | |||||
| list.value = reset ? rows : list.value.concat(rows) | |||||
| total.value = Number(data.total || rows.length) | |||||
| counts.value = normalizeCounts(data.counts || {}) | |||||
| scheduleRefresh() | |||||
| } catch (e) { | |||||
| if (!silent) toast.add({ title: e.message || '作品加载失败', color: 'error' }) | |||||
| } finally { | } finally { | ||||
| loading.value = false | loading.value = false | ||||
| } | } | ||||
| } | } | ||||
| // 加载更多 | |||||
| function normalizeCounts(data = {}) { | |||||
| return { | |||||
| all: Number(data.all || 0), | |||||
| ainame: Number(data.ainame || 0), | |||||
| anime: Number(data.anime || 0), | |||||
| image: Number(data.image || 0), | |||||
| photo: Number(data.photo || 0), | |||||
| oldphoto: Number(data.oldphoto || 0), | |||||
| portrait: Number(data.portrait || 0), | |||||
| } | |||||
| } | |||||
| // 有生成中的作品时自动轮询刷新 | |||||
| function scheduleRefresh() { | |||||
| clearTimer() | |||||
| if (list.value.some(item => Number(item.status) === 0)) { | |||||
| timer = setTimeout(() => fetchList(true, true), 3000) | |||||
| } | |||||
| } | |||||
| function clearTimer() { | |||||
| if (timer) clearTimeout(timer) | |||||
| timer = null | |||||
| } | |||||
| function changeTab(key) { | |||||
| if (activeTab.value === key) return | |||||
| activeTab.value = key | |||||
| fetchList(true) | |||||
| } | |||||
| function loadMore() { | function loadMore() { | ||||
| page.value++ | |||||
| loadWorks(false) | |||||
| if (loading.value || !hasMore.value) return | |||||
| page.value += 1 | |||||
| fetchList() | |||||
| } | } | ||||
| // 切换分类 | |||||
| watch(activeTab, () => { | |||||
| loadWorks(true) | |||||
| }) | |||||
| // 点击作品用弹窗查看详情(不再跳回创作页) | |||||
| const detailOpen = ref(false) | |||||
| const currentWork = ref({}) | |||||
| function openWork(item) { | |||||
| // 生成中的作品也可打开,弹窗内会显示进度状态 | |||||
| currentWork.value = item | |||||
| detailOpen.value = true | |||||
| } | |||||
| onMounted(() => { | |||||
| if (isLogin.value) { | |||||
| loadWorks(true) | |||||
| function statusClass(item) { | |||||
| const status = Number(item.status) | |||||
| if (status === 0) return 'bg-[var(--c-fff5e8)] text-[var(--c-ff8a00)]' | |||||
| if (status < 0) return 'bg-[var(--c-fff0ef)] text-[var(--c-f04438)]' | |||||
| return 'bg-[var(--c-ecfbf3)] text-[var(--c-16a15d)]' | |||||
| } | |||||
| function typeText(type) { | |||||
| const map = { | |||||
| ainame: 'AI取名', | |||||
| anime: '动漫头像', | |||||
| image: 'AI作品', | |||||
| photo: '证件照', | |||||
| oldphoto: '老照片', | |||||
| portrait: '形象照', | |||||
| } | } | ||||
| }) | |||||
| return map[type] || 'AI作品' | |||||
| } | |||||
| onMounted(() => fetchList(true)) | |||||
| onUnmounted(() => clearTimer()) | |||||
| watch(isLogin, (v) => { if (v) fetchList(true) }) | |||||
| </script> | </script> | ||||
| @@ -0,0 +1,112 @@ | |||||
| /** | |||||
| * AI 取名结果解析(从 ai-name 页面抽出,供创作页与详情弹窗共用) | |||||
| * | |||||
| * 后端返回的 content 可能是: | |||||
| * 1) JSON({ summary, names: [] }) | |||||
| * 2) JSON Lines(每行一个 {type:'summary'|'name'}) | |||||
| * 3) 【名字】+ 拼音/评分/出处… 的标记文本 | |||||
| */ | |||||
| export function parseNameResult(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('{') | |||||
| const 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 { /* 忽略非 JSON 行 */ } | |||||
| }) | |||||
| 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] || '' | |||||
| const name = resolveNameBlockTitle(m[1], body) | |||||
| d.names.push({ | |||||
| name, | |||||
| pinyin: matchField(body, '拼音'), | |||||
| score: parseInt(matchField(body, '评分')) || 90, | |||||
| source: matchField(body, '出处'), | |||||
| wuxing: matchField(body, '五行'), | |||||
| meaning: matchField(body, '寓意'), | |||||
| tone: matchField(body, '音律'), | |||||
| reason: matchField(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 matchField(body, label) { | |||||
| const r = new RegExp(`${label}[::]([^\\n\\r]*)`) | |||||
| const m = String(body || '').match(r) | |||||
| return m ? m[1].trim() : '' | |||||
| } | |||||
| /** 名字详情展示字段 */ | |||||
| export const NAME_FIELDS = [ | |||||
| { key: 'source', label: '出处' }, | |||||
| { key: 'wuxing', label: '五行' }, | |||||
| { key: 'meaning', label: '寓意' }, | |||||
| { key: 'tone', label: '音律' }, | |||||
| { key: 'reason', label: '推荐' }, | |||||
| ] | |||||
| /** 把名字结果拼成可复制文本 */ | |||||
| export function buildNameCopyText(result) { | |||||
| const names = result?.names || [] | |||||
| if (!names.length) return '' | |||||
| const lines = [] | |||||
| if (result.summary) lines.push(result.summary) | |||||
| names.forEach((item) => { | |||||
| lines.push(`${item.name}${item.score ? `(${item.score}分)` : ''}`) | |||||
| NAME_FIELDS.forEach((f) => { | |||||
| if (item[f.key]) lines.push(`${f.label}:${item[f.key]}`) | |||||
| }) | |||||
| if (item.pinyin) lines.push(`拼音:${item.pinyin}`) | |||||
| lines.push('') | |||||
| }) | |||||
| return lines.join('\n') | |||||
| } | |||||
| @@ -0,0 +1,94 @@ | |||||
| /** | |||||
| * 后台富文本清洗(协议、公告等静态内容) | |||||
| * | |||||
| * 背景:html 表里的正文是 Word / 旧编辑器导出的,直接渲染有两类问题: | |||||
| * 1. 大量 `<p><span> </span></p>` 空段落,叠加段间距后出现大片空白; | |||||
| * 2. 几乎每个 span 都带内联 `font-family:宋体;font-size:14px;color:#000`, | |||||
| * 内联样式优先级最高,会盖掉页面样式,暗色模式下黑字配深底完全不可读。 | |||||
| * | |||||
| * 处理方式:删掉空段落,剥掉会影响排版与主题的内联属性,保留加粗、下划线等语义样式。 | |||||
| * 与 ai_uniapp_v2/utils/richtext.js 保持同一套规则(两个仓库独立,无法共享模块)。 | |||||
| */ | |||||
| const DROP_STYLE_PROPS = [ | |||||
| 'font-family', | |||||
| 'font-size', | |||||
| 'color', | |||||
| 'background', | |||||
| 'background-color', | |||||
| 'line-height', | |||||
| 'margin', | |||||
| 'margin-top', | |||||
| 'margin-bottom', | |||||
| 'margin-left', | |||||
| 'margin-right', | |||||
| 'padding', | |||||
| 'padding-top', | |||||
| 'padding-bottom', | |||||
| 'width', | |||||
| 'height', | |||||
| ] | |||||
| /** 判断一段 html 是否只有空白内容(空格、 、<br>、空标签) */ | |||||
| function isBlankFragment(html) { | |||||
| const text = String(html || '') | |||||
| .replace(/<br\s*\/?>/gi, '') | |||||
| .replace(/<[^>]+>/g, '') | |||||
| .replace(/ | |\u00a0/gi, '') | |||||
| .replace(/\s/g, '') | |||||
| return text.length === 0 | |||||
| } | |||||
| function cleanStyleValue(style) { | |||||
| return String(style || '') | |||||
| .split(';') | |||||
| .map(item => item.trim()) | |||||
| .filter(Boolean) | |||||
| .filter((item) => { | |||||
| const prop = item.split(':')[0].trim().toLowerCase() | |||||
| return prop && !DROP_STYLE_PROPS.includes(prop) | |||||
| }) | |||||
| .join('; ') | |||||
| } | |||||
| /** | |||||
| * @param {string} html 后台返回的富文本 | |||||
| * @return {string} 清洗后的富文本 | |||||
| */ | |||||
| export function cleanRichText(html) { | |||||
| if (!html) return '' | |||||
| let text = String(html) | |||||
| // Word 导出的无用标签与内嵌样式表 | |||||
| text = text | |||||
| .replace(/<\/?o:p[^>]*>/gi, '') | |||||
| .replace(/<!--[\s\S]*?-->/g, '') | |||||
| .replace(/<meta[^>]*>/gi, '') | |||||
| .replace(/<style[\s\S]*?<\/style>/gi, '') | |||||
| .replace(/<script[\s\S]*?<\/script>/gi, '') | |||||
| // 剥掉内联的字体/颜色/间距声明,style 清空后连属性一起去掉 | |||||
| text = text.replace(/\sstyle\s*=\s*"([^"]*)"/gi, (match, style) => { | |||||
| const kept = cleanStyleValue(style) | |||||
| return kept ? ` style="${kept}"` : '' | |||||
| }) | |||||
| text = text.replace(/\sstyle\s*=\s*'([^']*)'/gi, (match, style) => { | |||||
| const kept = cleanStyleValue(style) | |||||
| return kept ? ` style="${kept}"` : '' | |||||
| }) | |||||
| text = text.replace(/<\/?font[^>]*>/gi, '') | |||||
| text = text.replace(/\sclass\s*=\s*"(?:Mso|xl)[^"]*"/gi, '') | |||||
| // 只剩空白的段落 / 标题直接删掉 | |||||
| text = text.replace(/<(p|div|h[1-6])\b[^>]*>([\s\S]*?)<\/\1>/gi, (match, tag, inner) => { | |||||
| return isBlankFragment(inner) ? '' : match | |||||
| }) | |||||
| // 连续 <br> 压缩为一个 | |||||
| text = text.replace(/(?:<br\s*\/?>\s*){2,}/gi, '<br>') | |||||
| return text.trim() | |||||
| } | |||||
| export default cleanRichText | |||||
| @@ -0,0 +1,3 @@ | |||||
| # Nuxt 4.5 强制要求 Node >= 22.19.0(见 README「前置条件」) | |||||
| [tools] | |||||
| node = "22.19.0" | |||||
| @@ -1,3 +1,5 @@ | |||||
| import { fileURLToPath } from 'node:url' | |||||
| // https://nuxt.com/docs/api/configuration/nuxt-config | // https://nuxt.com/docs/api/configuration/nuxt-config | ||||
| export default defineNuxtConfig({ | export default defineNuxtConfig({ | ||||
| compatibilityDate: '2025-07-15', | compatibilityDate: '2025-07-15', | ||||
| @@ -9,6 +11,18 @@ export default defineNuxtConfig({ | |||||
| css: ['~/assets/css/main.css'], | css: ['~/assets/css/main.css'], | ||||
| /** | |||||
| * 暗色模式:@nuxtjs/color-mode 由 @nuxt/ui 自带(classSuffix 已被置空,即 <html class="dark">)。 | |||||
| * 默认保持亮色,不跟随系统;用户在页头手动切换后写入 cookie 持久化。 | |||||
| */ | |||||
| colorMode: { | |||||
| preference: 'light', | |||||
| fallback: 'light', | |||||
| classSuffix: '', | |||||
| storage: 'cookie', | |||||
| storageKey: 'ai-color-mode', | |||||
| }, | |||||
| // 禁用 @nuxt/fonts 远程字体提供商(内网环境无法访问 Google Fonts) | // 禁用 @nuxt/fonts 远程字体提供商(内网环境无法访问 Google Fonts) | ||||
| ui: { | ui: { | ||||
| fonts: false, | fonts: false, | ||||
| @@ -19,22 +33,102 @@ export default defineNuxtConfig({ | |||||
| public: { | public: { | ||||
| // ThinkJS ai_api 地址,按环境覆盖 | // ThinkJS ai_api 地址,按环境覆盖 | ||||
| apiBase: process.env.API_BASE || 'http://192.168.31.168:16888', | apiBase: process.env.API_BASE || 'http://192.168.31.168:16888', | ||||
| // 邀请链接正式域名;未配置时自动使用当前访问域名。 | |||||
| siteUrl: process.env.NUXT_PUBLIC_SITE_URL || '', | |||||
| }, | }, | ||||
| }, | }, | ||||
| // SSR / SEO | // SSR / SEO | ||||
| ssr: true, | ssr: true, | ||||
| /** | |||||
| * 兼容原 PC 站的自定义路由(SEO 不断链) | |||||
| * 原站分页形式:/info2、/info/new2、/info/course2、/info/what2。 | |||||
| * 已迁移工具继续以旧站已收录路径作为主入口。 | |||||
| */ | |||||
| hooks: { | |||||
| 'pages:extend'(pages) { | |||||
| /* | |||||
| Windows 下长时间运行的 dev 进程偶尔会漏掉新增页面文件的 add 事件, | |||||
| 表现为:SSR 已命中页面并吐出内容,客户端水合后 vue-router 匹配不到而进入 404。 | |||||
| 这里对所有新增页面显式兜底;已被自动扫描到的路径会跳过,不会重复注册。 | |||||
| 新增 app/pages 下的页面时,记得同步加到这个清单里。 | |||||
| */ | |||||
| const fallbackPages = [ | |||||
| { name: 'invite', path: '/invite', file: './app/pages/invite/index.vue' }, | |||||
| { name: 'invite-landing', path: '/invite/landing', file: './app/pages/invite/landing.vue' }, | |||||
| { name: 'invite-records', path: '/invite/records', file: './app/pages/invite/records.vue' }, | |||||
| { name: 'terms', path: '/terms', file: './app/pages/terms.vue' }, | |||||
| { name: 'privacy', path: '/privacy', file: './app/pages/privacy.vue' }, | |||||
| { name: 'earn-points', path: '/earn-points', file: './app/pages/earn-points.vue' }, | |||||
| { name: 'portrait-photo', path: '/portrait-photo', file: './app/pages/portrait-photo.vue' }, | |||||
| { name: 'old-photo', path: '/old-photo', file: './app/pages/old-photo.vue' }, | |||||
| { name: 'id-photo', path: '/id-photo', file: './app/pages/id-photo.vue' }, | |||||
| { name: 'recharge', path: '/recharge', file: './app/pages/recharge.vue' }, | |||||
| { name: 'bill', path: '/bill', file: './app/pages/bill.vue' }, | |||||
| { name: 'works', path: '/works', file: './app/pages/works.vue' }, | |||||
| ] | |||||
| for (const page of fallbackPages) { | |||||
| if (pages.some(item => item.path === page.path)) continue | |||||
| pages.push({ | |||||
| ...page, | |||||
| file: fileURLToPath(new URL(page.file, import.meta.url)), | |||||
| }) | |||||
| } | |||||
| // 复用已有页面组件,追加原站的分页路径(/info2 = 第 2 页) | |||||
| const legacy = [ | |||||
| { base: '/info', name: 'legacy-info-page' }, | |||||
| { base: '/info/new', name: 'legacy-info-new-page' }, | |||||
| { base: '/info/course', name: 'legacy-info-course-page' }, | |||||
| { base: '/info/what', name: 'legacy-info-what-page' }, | |||||
| ] | |||||
| for (const item of legacy) { | |||||
| const origin = pages.find(p => p.path === item.base) | |||||
| if (!origin) continue | |||||
| pages.push({ | |||||
| name: item.name, | |||||
| path: `${item.base}:page(\\d+)`, | |||||
| file: origin.file, | |||||
| children: [], | |||||
| }) | |||||
| pages.push({ | |||||
| name: `${item.name}-html`, | |||||
| path: `${item.base}:page(\\d+).html`, | |||||
| file: origin.file, | |||||
| children: [], | |||||
| }) | |||||
| } | |||||
| }, | |||||
| }, | |||||
| // 旧 ThinkJS 自动 controller 地址及重构早期地址统一 301 到已收录主路径。 | |||||
| routeRules: { | |||||
| '/ai-name': { redirect: { to: '/writing/naming/', statusCode: 301 } }, | |||||
| '/anime-avatar': { redirect: { to: '/draw/anime/', statusCode: 301 } }, | |||||
| '/writing/naming.html': { redirect: { to: '/writing/naming/', statusCode: 301 } }, | |||||
| '/draw/anime.html': { redirect: { to: '/draw/anime/', statusCode: 301 } }, | |||||
| '/index.html': { redirect: { to: '/', statusCode: 301 } }, | |||||
| '/anime/index.html': { redirect: { to: '/draw/anime/', statusCode: 301 } }, | |||||
| '/pic/index.html': { redirect: { to: '/pic/', statusCode: 301 } }, | |||||
| '/news/index.html': { redirect: { to: '/info', statusCode: 301 } }, | |||||
| '/news/list.html': { redirect: { to: '/info/new', statusCode: 301 } }, | |||||
| '/news/course.html': { redirect: { to: '/info/course', statusCode: 301 } }, | |||||
| '/news/ask.html': { redirect: { to: '/info/what', statusCode: 301 } }, | |||||
| '/my/invite.html': { redirect: { to: '/invite', statusCode: 301 } }, | |||||
| }, | |||||
| app: { | app: { | ||||
| head: { | head: { | ||||
| title: '奇想宇宙', | |||||
| titleTemplate: '%s', | |||||
| title: 'AI在线网站-你的AI伙伴让创作更简单', | |||||
| meta: [ | meta: [ | ||||
| { charset: 'utf-8' }, | { charset: 'utf-8' }, | ||||
| { name: 'viewport', content: 'width=device-width, initial-scale=1' }, | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, | ||||
| { name: 'description', content: 'AI 问答、AI 绘画、AI 音乐,探索无限可能' }, | |||||
| { name: 'keywords', content: 'AI,人工智能,Midjourney,AI绘画,AI音乐,AI问答' }, | |||||
| { name: 'description', content: 'AI在线是一个 AI 工具的集合地,可以找到各种各样的 AI 工具,满足你的不同需求。无论你是想翻译一段文字,还是想生成一篇文章,或者是想制作一张图片,AI在线都能为你提供帮助, AI 工具大全宝库!' }, | |||||
| { name: 'keywords', content: 'AI,AI在线,AI伙伴,免费AI网站' }, | |||||
| ], | ], | ||||
| link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }], | |||||
| link: [{ rel: 'icon', type: 'image/png', href: '/logo.png' }], | |||||
| }, | }, | ||||
| }, | }, | ||||
| }) | }) | ||||
| @@ -0,0 +1,2 @@ | |||||
| User-agent: * | |||||
| Allow:/ | |||||
| @@ -0,0 +1,398 @@ | |||||
| /** | |||||
| * 暗色模式 codemod(一次性脚本,保留以便后续新增页面时复跑) | |||||
| * | |||||
| * 做两件事: | |||||
| * 1. 扫描 app/ 下所有 Tailwind 任意值里的硬编码颜色(#hex / rgba() / bg-white 家族), | |||||
| * 生成 app/assets/css/theme.css:亮色变量值 = 原色值(亮色视觉零变化), | |||||
| * .dark 下按语义分桶给出暗色取值。 | |||||
| * 2. 把源码中的硬编码色值替换为 var(--c-xxxxxx) / var(--sh-n)。 | |||||
| * | |||||
| * 用法:node scripts/gen-theme.mjs [--dry] | |||||
| * 只处理 <template> 与 <style> 段,避免动到 <script> 里作为数据传给后端的颜色值。 | |||||
| */ | |||||
| import { readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs' | |||||
| import { join, dirname } from 'node:path' | |||||
| import { fileURLToPath } from 'node:url' | |||||
| const here = dirname(fileURLToPath(import.meta.url)) | |||||
| const appDir = join(here, '..', 'app') | |||||
| const cssOut = join(appDir, 'assets', 'css', 'theme.css') | |||||
| const dry = process.argv.includes('--dry') | |||||
| /* ---------------- 颜色工具 ---------------- */ | |||||
| function normHex(h) { | |||||
| let s = h.replace('#', '').toLowerCase() | |||||
| if (s.length === 3) s = s.split('').map(c => c + c).join('') | |||||
| if (s.length === 8) s = s.slice(0, 6) | |||||
| return s | |||||
| } | |||||
| function hexToRgb(hex) { | |||||
| const s = normHex(hex) | |||||
| return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)] | |||||
| } | |||||
| function rgbToHsl([r, g, b]) { | |||||
| r /= 255; g /= 255; b /= 255 | |||||
| const max = Math.max(r, g, b), min = Math.min(r, g, b) | |||||
| const l = (max + min) / 2 | |||||
| let h = 0, s = 0 | |||||
| const d = max - min | |||||
| if (d) { | |||||
| s = l > 0.5 ? d / (2 - max - min) : d / (max + min) | |||||
| if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) | |||||
| else if (max === g) h = (b - r) / d + 2 | |||||
| else h = (r - g) / d + 4 | |||||
| h *= 60 | |||||
| } | |||||
| return { h, s, l, chroma: d } | |||||
| } | |||||
| function hslToHex(h, s, l) { | |||||
| h = ((h % 360) + 360) % 360 | |||||
| const c = (1 - Math.abs(2 * l - 1)) * s | |||||
| const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) | |||||
| const m = l - c / 2 | |||||
| let rgb | |||||
| if (h < 60) rgb = [c, x, 0] | |||||
| else if (h < 120) rgb = [x, c, 0] | |||||
| else if (h < 180) rgb = [0, c, x] | |||||
| else if (h < 240) rgb = [0, x, c] | |||||
| else if (h < 300) rgb = [x, 0, c] | |||||
| else rgb = [c, 0, x] | |||||
| return '#' + rgb.map(v => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('') | |||||
| } | |||||
| const clamp = (v, a, b) => Math.min(b, Math.max(a, v)) | |||||
| /** | |||||
| * 亮色 -> 暗色。 | |||||
| * | |||||
| * 同一个色值在项目里既可能当文字、当背景、也可能当边框(例如 #172033 既是标题色, | |||||
| * 也是证件照页选中态的深底;#e8eef7 主要是卡片描边),因此按 | |||||
| * 「用法角色 + 彩度 + 亮度」决定映射方向: | |||||
| * - text:深色文字提亮,浅灰文字压成暗色下的弱文字; | |||||
| * - line(border / ring / divide / outline):亮色下的浅描边必须映射成「比面板更亮」 | |||||
| * 的线色,否则暗色下边框会和面板同色、直接看不见; | |||||
| * - surface:浅色底转深色底;本来就是深色的底(遮罩、深色 hero)原样保留。 | |||||
| * @param {string} hex 亮色取值 | |||||
| * @param {'text'|'line'|'surface'} role 该色值在源码中的主要用法 | |||||
| */ | |||||
| function darkFor(hex, role = 'surface') { | |||||
| const { h, s, l, chroma } = rgbToHsl(hexToRgb(hex)) | |||||
| const isText = role === 'text' | |||||
| const isLine = role === 'line' | |||||
| // 鲜艳的品牌色 / 语义色:保持色相与识别度 | |||||
| if (chroma >= 0.45) { | |||||
| if (isText && l < 0.62) return hslToHex(h, Math.min(s, 0.95), 0.65) | |||||
| if (isLine && l >= 0.85) return hslToHex(h, clamp(s, 0.25, 0.55), 0.42) // 彩色描边要看得见 | |||||
| if (!isText && !isLine && l >= 0.85) return hslToHex(h, clamp(s, 0.2, 0.55), 0.30) | |||||
| return hex | |||||
| } | |||||
| // 带色调的浅底(蓝/绿/橙/红/紫的 tint) | |||||
| if (chroma >= 0.055) { | |||||
| if (isText) { | |||||
| return l < 0.45 | |||||
| ? hslToHex(h, Math.min(s, 0.20), 0.88) | |||||
| : hslToHex(h, Math.min(s, 0.25), 0.62) | |||||
| } | |||||
| if (isLine) { | |||||
| if (l < 0.35) return hex // 本来就是深色描边 | |||||
| if (l >= 0.90) return hslToHex(h, Math.min(s, 0.22), 0.32) // 极浅描边 -> 可见线色 | |||||
| if (l >= 0.70) return hslToHex(h, Math.min(s, 0.28), 0.38) | |||||
| return hslToHex(h, Math.min(s, 0.32), 0.46) | |||||
| } | |||||
| if (l < 0.35) return hex | |||||
| if (l >= 0.93) return hslToHex(h, Math.min(s, 0.42), 0.16) | |||||
| if (l >= 0.85) return hslToHex(h, Math.min(s, 0.45), 0.20) | |||||
| if (l >= 0.70) return hslToHex(h, Math.min(s, 0.45), 0.26) | |||||
| return hslToHex(h, Math.min(s, 0.40), 0.34) | |||||
| } | |||||
| // 中性灰:暗色下统一到一套固定层级,避免同层级颜色互相不一致 | |||||
| if (isText) { | |||||
| if (l < 0.45) return '#e9eef6' // 标题 | |||||
| if (l < 0.62) return '#aebdd0' // 正文 | |||||
| return '#8494a8' // 弱文字 | |||||
| } | |||||
| if (isLine) { | |||||
| if (l < 0.35) return hex | |||||
| if (l >= 0.90) return '#2f3a49' // 常规描边(比面板亮一档) | |||||
| if (l >= 0.75) return '#3a4757' // 稍重的描边 | |||||
| return '#485768' | |||||
| } | |||||
| if (l < 0.35) return hex // 已是深底(遮罩等),沿用 | |||||
| if (l >= 0.99) return '#171e2a' // 面板 | |||||
| if (l >= 0.955) return '#1b2331' // 次级面板 | |||||
| if (l >= 0.90) return '#202939' | |||||
| if (l >= 0.80) return '#2a3442' | |||||
| return '#3a4553' | |||||
| } | |||||
| /** 人工校准的关键层级(覆盖算法结果,保证主要面板/文字层级干净) */ | |||||
| const CURATED = { | |||||
| ffffff: '#171e2a', // 卡片、弹层、页头 | |||||
| fff: '#171e2a', | |||||
| f3f7fb: '#090d13', // 页面底色(与面板拉开对比,无阴影时靠它区分层次) | |||||
| f4f7fb: '#090d13', | |||||
| f7f9fd: '#1d2632', // 次级面板 | |||||
| f1f5fa: '#1d2632', | |||||
| eef2f7: '#2f3a49', // 也是描边(15 处 border),不能按浅底压暗,否则边线看不见 | |||||
| e8eef7: '#2f3a49', // 主描边 | |||||
| eef7ff: '#152a40', // 品牌浅底(选中/hover) | |||||
| e0f0ff: '#1b3550', | |||||
| // 品牌蓝钉死不变:它既是文字色也是按钮底色,提亮会让按钮上的白字掉到 3:1 以下; | |||||
| // 而 #0b8cff 落在暗色面板上本身就有约 5:1 的对比,不需要动。 | |||||
| '0b8cff': '#0b8cff', | |||||
| '1f8cff': '#1f8cff', | |||||
| '3aa9ff': '#3aa9ff', | |||||
| '0a7ce0': '#0a7ce0', | |||||
| '172033': '#e9eef6', // 标题(注意:#0f172a / #0b1324 在本项目里是深色底与遮罩,不在此列) | |||||
| '4b5b70': '#aebdd0', // 正文 | |||||
| '5b6b80': '#a2b2c6', | |||||
| '66758a': '#97a7bb', | |||||
| '8a97a8': '#8494a8', // 弱文字 | |||||
| '8a95a6': '#8494a8', | |||||
| a0aabb: '#7c8b9f', | |||||
| '9aa5b5': '#7c8b9f', | |||||
| f04438: '#ff6f63', // 语义-危险 | |||||
| e5484d: '#ff6f63', | |||||
| '17a65a': '#38c884', // 语义-成功 | |||||
| '159a54': '#38c884', | |||||
| e85f1c: '#ff9455', // 语义-警示 | |||||
| ff681f: '#ff9455', | |||||
| '7a5af8': '#a68cff', // 语义-紫 | |||||
| } | |||||
| /* ---------------- 需要替换的 Tailwind 具名颜色 ---------------- */ | |||||
| // 只映射「作为表面/文字层级」的具名色;text-white、bg-white/低透明度等装饰用法保持原样 | |||||
| const NAMED = { | |||||
| 'gray-50': 'f9fafb', | |||||
| 'gray-100': 'f3f4f6', | |||||
| 'gray-200': 'e5e7eb', | |||||
| 'slate-100': 'f1f5f9', | |||||
| 'slate-200': 'e2e8f0', | |||||
| 'slate-300': 'cbd5e1', | |||||
| 'slate-400': '94a3b8', | |||||
| 'slate-500': '64748b', | |||||
| 'slate-600': '475569', | |||||
| 'slate-900': '0f172a', | |||||
| } | |||||
| /* ---------------- 收集文件 ---------------- */ | |||||
| const files = [] | |||||
| ;(function walk(dir) { | |||||
| for (const name of readdirSync(dir)) { | |||||
| const p = join(dir, name) | |||||
| if (statSync(p).isDirectory()) walk(p) | |||||
| else if (name.endsWith('.vue')) files.push(p) | |||||
| } | |||||
| })(appDir) | |||||
| const hexUsed = new Set() | |||||
| const shadowColors = new Map() // var 名 -> 亮色 rgba 值 | |||||
| const roleCount = new Map() // hex -> { text, line, surface } | |||||
| const TEXT_UTILS = new Set(['text', 'placeholder', 'decoration', 'caret']) | |||||
| const LINE_UTILS = new Set(['border', 'ring', 'divide', 'outline']) | |||||
| function utilRole(util) { | |||||
| if (TEXT_UTILS.has(util)) return 'text' | |||||
| if (LINE_UTILS.has(util)) return 'line' | |||||
| return 'surface' | |||||
| } | |||||
| function addRole(hex, role) { | |||||
| if (!roleCount.has(hex)) roleCount.set(hex, { text: 0, line: 0, surface: 0 }) | |||||
| roleCount.get(hex)[role]++ | |||||
| } | |||||
| function countRoles(src) { | |||||
| // Tailwind 工具类 | |||||
| for (const m of src.matchAll(/([a-z]+)-\[var\(--c-([0-9a-f]{6})\)\]/g)) { | |||||
| addRole(m[2], utilRole(m[1])) | |||||
| } | |||||
| // <style> 段里的 CSS 声明(富文本样式等) | |||||
| for (const block of src.matchAll(/<style[\s\S]*?<\/style>/g)) { | |||||
| for (const m of block[0].matchAll(/(^|[\s;{])color\s*:\s*var\(--c-([0-9a-f]{6})\)/g)) { | |||||
| addRole(m[2], 'text') | |||||
| } | |||||
| for (const m of block[0].matchAll(/(border|outline)[\w-]*\s*:[^;]*var\(--c-([0-9a-f]{6})\)/g)) { | |||||
| addRole(m[2], 'line') | |||||
| } | |||||
| for (const m of block[0].matchAll(/(background|box-shadow|fill|stroke)[\w-]*\s*:[^;]*var\(--c-([0-9a-f]{6})\)/g)) { | |||||
| addRole(m[2], 'surface') | |||||
| } | |||||
| } | |||||
| } | |||||
| /** 取用得最多的角色;数量相同时,line 优先于 surface(描边看不见比底色偏差更刺眼) */ | |||||
| function roleOf(hex) { | |||||
| const rec = roleCount.get(hex) | |||||
| if (!rec) return 'surface' | |||||
| if (rec.text > rec.line && rec.text > rec.surface) return 'text' | |||||
| if (rec.line >= rec.surface && rec.line > 0) return 'line' | |||||
| return 'surface' | |||||
| } | |||||
| /** | |||||
| * 阴影色 var 名由 rgba 数值直接编码(--sh-r-g-b-a千分位), | |||||
| * 这样脚本可重复执行:即使源码里已经是 var(--sh-...),也能还原出亮色取值。 | |||||
| */ | |||||
| function shadowVar(raw) { | |||||
| const nums = raw.match(/[\d.]+/g) || [] | |||||
| const [r, g, b] = nums.slice(0, 3).map(Number) | |||||
| let a = nums.length > 3 ? Number(nums[3]) : 1 | |||||
| if (raw.includes('%')) a = a / 100 | |||||
| const name = `--sh-${r}-${g}-${b}-${Math.round(a * 1000)}` | |||||
| shadowColors.set(name, `rgba(${r}, ${g}, ${b}, ${+a.toFixed(3)})`) | |||||
| return name | |||||
| } | |||||
| function transform(src) { | |||||
| // 已替换过的令牌先登记,保证脚本可重复执行 | |||||
| for (const m of src.matchAll(/var\(--c-([0-9a-f]{6})\)/g)) hexUsed.add(m[1]) | |||||
| for (const m of src.matchAll(/var\(--sh-(\d+)-(\d+)-(\d+)-(\d+)\)/g)) { | |||||
| shadowColors.set(`--sh-${m[1]}-${m[2]}-${m[3]}-${m[4]}`, `rgba(${m[1]}, ${m[2]}, ${m[3]}, ${+(Number(m[4]) / 1000).toFixed(3)})`) | |||||
| } | |||||
| // 方括号里的 hex 一定是 Tailwind 任意值(含 <script> 里的 class 字符串),全文替换。 | |||||
| // 复合任意值也覆盖,例如 shadow-[inset_0_0_0_1px_#edf2f7]、bg-[linear-gradient(...,#edf2f8_25%,...)]。 | |||||
| // 裸写的 '#438edb'(如证件照背景色,会作为数据传给后端)不在方括号里,不会被动到。 | |||||
| // 注意:hex 后面不能用 \b —— 任意值里的空格被写成下划线(#edf2f8_25%), | |||||
| // 下划线是单词字符,\b 不成立会漏改。这里用「后面不是 hex 字符」来收尾。 | |||||
| let out = src.replace(/\[[^\s"'`\]]*#[0-9a-fA-F]{3,8}[^\s"'`]*?\]/g, token => | |||||
| token.replace(/#([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g, (_all, hx) => { | |||||
| const key = normHex(hx) | |||||
| hexUsed.add(key) | |||||
| return `var(--c-${key})` | |||||
| }), | |||||
| ) | |||||
| // 其余规则(rgba、具名色)跳过 <script> 段,避免误改脚本里作为数据的颜色。 | |||||
| // 注意:不能用 <template>...</template> 去框定范围 —— 页面里有 <template #slot> 嵌套, | |||||
| // 非贪婪匹配会在第一个 </template> 处收尾,导致后半个模板漏改。 | |||||
| const parts = [] | |||||
| const re = /<script[\s\S]*?<\/script>/g | |||||
| let last = 0 | |||||
| let m | |||||
| while ((m = re.exec(out))) { | |||||
| parts.push(['edit', out.slice(last, m.index)]) | |||||
| parts.push(['keep', m[0]]) | |||||
| last = m.index + m[0].length | |||||
| } | |||||
| parts.push(['edit', out.slice(last)]) | |||||
| out = parts.map(([kind, text]) => (kind === 'keep' ? text : editSection(text))).join('') | |||||
| // <style> 段里的裸 hex(富文本 prose 样式、滚动条等) | |||||
| out = out.replace(/<style[\s\S]*?<\/style>/g, block => | |||||
| block.replace(/#([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g, (_all, hx) => { | |||||
| const key = normHex(hx) | |||||
| hexUsed.add(key) | |||||
| return `var(--c-${key})` | |||||
| }), | |||||
| ) | |||||
| return out | |||||
| } | |||||
| function editSection(text) { | |||||
| let out = text | |||||
| // 2) 阴影等复合任意值里的 rgb/rgba | |||||
| out = out.replace(/rgba?\([^)]*\)/g, (raw) => { | |||||
| // style 段里的 CSS 变量定义本身不处理 | |||||
| const name = shadowVar(raw.replace(/\s+/g, ' ').trim()) | |||||
| return `var(${name})` | |||||
| }) | |||||
| // 3) 具名中性色(表面/文字层级) | |||||
| for (const [named, hx] of Object.entries(NAMED)) { | |||||
| const re = new RegExp(`\\b(bg|text|border|ring|from|via|to|divide|placeholder)-${named}\\b(?!/)`, 'g') | |||||
| out = out.replace(re, (_a, util) => { | |||||
| hexUsed.add(hx) | |||||
| return `${util}-[var(--c-${hx})]` | |||||
| }) | |||||
| } | |||||
| // 4) white:仅表面类用法替换,text-white 与低透明度装饰保持不变 | |||||
| out = out.replace(/\b(bg|from|via|to|divide)-white\b(?!\/)/g, (_a, util) => { | |||||
| hexUsed.add('ffffff') | |||||
| return `${util}-[var(--c-ffffff)]` | |||||
| }) | |||||
| out = out.replace(/\b(border|ring)-white\b(?!\/)/g, (_a, util) => { | |||||
| hexUsed.add('e8eef7') | |||||
| return `${util}-[var(--c-e8eef7)]` | |||||
| }) | |||||
| // bg-white/70 及以上视为面板(半透明毛玻璃面板) | |||||
| out = out.replace(/\bbg-white\/(\d{2,3})\b/g, (all, a) => { | |||||
| if (Number(a) < 70) return all | |||||
| hexUsed.add('ffffff') | |||||
| return `bg-[var(--c-ffffff)]/${a}` | |||||
| }) | |||||
| return out | |||||
| } | |||||
| /* ---------------- 执行替换 ---------------- */ | |||||
| let changed = 0 | |||||
| for (const f of files) { | |||||
| const src = readFileSync(f, 'utf8') | |||||
| const next = transform(src) | |||||
| countRoles(next) | |||||
| if (next !== src) { | |||||
| changed++ | |||||
| if (!dry) writeFileSync(f, next, 'utf8') | |||||
| } | |||||
| } | |||||
| /* ---------------- 生成 theme.css ---------------- */ | |||||
| /** | |||||
| * 阴影色的暗色取值。 | |||||
| * | |||||
| * 亮色下这些投影是「浅灰蓝 + 很低透明度」,直接搬到暗色底上等于不存在。 | |||||
| * 处理方式: | |||||
| * - 品牌色光晕(按钮、选中态)保留色相并略微加强,暗色下依然是发光效果; | |||||
| * - 中性投影换成近黑并显著提高透明度,才能在深色面板上压出层次。 | |||||
| * 卡片边缘主要靠描边(line 角色)和「面板 / 页面底」的明度差来区分,不指望投影。 | |||||
| */ | |||||
| function darkShadow(raw) { | |||||
| const nums = raw.match(/[\d.]+/g) || [] | |||||
| const [r, g, b] = nums.slice(0, 3).map(Number) | |||||
| const a = nums.length > 3 ? Number(nums[3]) : 1 | |||||
| const isBrand = b > 180 && g > 90 && r < 80 | |||||
| if (isBrand) return `rgba(${r}, ${g}, ${b}, ${Math.min(+(a * 1.3).toFixed(3), 0.5)})` | |||||
| return `rgba(0, 0, 0, ${clamp(+(a * 3.2).toFixed(3), 0.28, 0.62)})` | |||||
| } | |||||
| const hexList = [...hexUsed].sort() | |||||
| const lines = [] | |||||
| lines.push('/**') | |||||
| lines.push(' * 主题色令牌(由 scripts/gen-theme.mjs 生成,勿手改生成段)') | |||||
| lines.push(' *') | |||||
| lines.push(' * :root 为亮色,取值与改造前的硬编码色值完全一致 —— 亮色视觉不变。') | |||||
| lines.push(' * html.dark 为暗色,按层级(页面底/面板/边框/文字/品牌/语义)分桶取值。') | |||||
| lines.push(' * 切换由 @nuxtjs/color-mode(Nuxt UI 内置)在 <html> 上加 .dark 类完成。') | |||||
| lines.push(' */') | |||||
| lines.push('') | |||||
| lines.push('/* Tailwind v4 默认 dark: 走 prefers-color-scheme,这里改为跟随 .dark 类 */') | |||||
| lines.push('@custom-variant dark (&:where(.dark, .dark *));') | |||||
| lines.push('') | |||||
| lines.push(':root {') | |||||
| lines.push(' color-scheme: light;') | |||||
| const shadowList = [...shadowColors.entries()].sort((a, b) => a[0].localeCompare(b[0])) | |||||
| for (const h of hexList) lines.push(` --c-${h}: #${h};`) | |||||
| for (const [name, raw] of shadowList) lines.push(` ${name}: ${raw};`) | |||||
| lines.push('}') | |||||
| lines.push('') | |||||
| lines.push('html.dark {') | |||||
| lines.push(' color-scheme: dark;') | |||||
| for (const h of hexList) lines.push(` --c-${h}: ${CURATED[h] || darkFor('#' + h, roleOf(h))};`) | |||||
| for (const [name, raw] of shadowList) lines.push(` ${name}: ${darkShadow(raw)};`) | |||||
| lines.push('}') | |||||
| lines.push('') | |||||
| if (!dry) writeFileSync(cssOut, lines.join('\n'), 'utf8') | |||||
| console.log(`files: ${files.length}, changed: ${changed}`) | |||||
| console.log(`hex tokens: ${hexList.length}, shadow tokens: ${shadowColors.size}`) | |||||
| console.log(dry ? '(dry run)' : `written: ${cssOut}`) | |||||
| @@ -10,6 +10,8 @@ | |||||
| * | * | ||||
| * 环境变量 API_BASE 可覆盖目标地址(生产环境指向 https://api.aionline.cc) | * 环境变量 API_BASE 可覆盖目标地址(生产环境指向 https://api.aionline.cc) | ||||
| */ | */ | ||||
| import { proxyRequest, sendWebResponse } from 'h3' | |||||
| export default defineEventHandler(async (event) => { | export default defineEventHandler(async (event) => { | ||||
| const apiBase = process.env.API_BASE || 'http://192.168.31.168:16888' | const apiBase = process.env.API_BASE || 'http://192.168.31.168:16888' | ||||
| @@ -33,14 +35,17 @@ export default defineEventHandler(async (event) => { | |||||
| if (contentType) { | if (contentType) { | ||||
| headers['content-type'] = contentType | headers['content-type'] = contentType | ||||
| } | } | ||||
| const isMultipart = Boolean(contentType?.includes('multipart/form-data')) | |||||
| // 透传签名的 header(nonce / timestr / token) | // 透传签名的 header(nonce / timestr / token) | ||||
| const clientNonce = event.headers.get('nonce') | const clientNonce = event.headers.get('nonce') | ||||
| const clientTimestr = event.headers.get('timestr') | const clientTimestr = event.headers.get('timestr') | ||||
| const clientToken = event.headers.get('token') | const clientToken = event.headers.get('token') | ||||
| const clientAccept = event.headers.get('accept') | |||||
| if (clientNonce) headers.nonce = clientNonce | if (clientNonce) headers.nonce = clientNonce | ||||
| if (clientTimestr) headers.timestr = clientTimestr | if (clientTimestr) headers.timestr = clientTimestr | ||||
| if (clientToken) headers.token = clientToken | if (clientToken) headers.token = clientToken | ||||
| if (clientAccept) headers.accept = clientAccept | |||||
| const fetchOptions = { method, headers } | const fetchOptions = { method, headers } | ||||
| @@ -49,26 +54,28 @@ export default defineEventHandler(async (event) => { | |||||
| const queryStr = new URLSearchParams(query).toString() | const queryStr = new URLSearchParams(query).toString() | ||||
| const fullUrl = queryStr ? url + '?' + queryStr : url | const fullUrl = queryStr ? url + '?' + queryStr : url | ||||
| // Multipart 必须直接转发原始请求流。先 readRawBody() 在部分 Chromium / | |||||
| // Nitro 组合下会一直等待流结束,导致上传请求既不到后端也不返回。 | |||||
| if (isMultipart) { | |||||
| return proxyRequest(event, fullUrl, { headers }) | |||||
| } | |||||
| // POST: 请求体 | // POST: 请求体 | ||||
| if (method === 'POST') { | if (method === 'POST') { | ||||
| const body = await readBody(event) | const body = await readBody(event) | ||||
| if (body && typeof body === 'object') { | 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() | |||||
| } | |||||
| fetchOptions.body = new URLSearchParams(body).toString() | |||||
| } | } | ||||
| } | } | ||||
| try { | try { | ||||
| // SSE 必须透传原始 Web Response;$fetch 会等待响应结束并尝试解析, | |||||
| // 导致浏览器无法逐块收到上游事件。 | |||||
| // Multipart 同样使用原生 fetch,确保二进制请求体和 boundary 原样到达上游。 | |||||
| if ((clientAccept || '').includes('text/event-stream')) { | |||||
| const upstreamResponse = await fetch(fullUrl, fetchOptions) | |||||
| return sendWebResponse(event, upstreamResponse) | |||||
| } | |||||
| return await $fetch(fullUrl, fetchOptions) | return await $fetch(fullUrl, fetchOptions) | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error('[Server Proxy Error]', url, error.message) | console.error('[Server Proxy Error]', url, error.message) | ||||