Ver código fonte

feat: migrate PC Nuxt AI tool pages

main
leiyun 1 mês atrás
pai
commit
64b466469e
65 arquivos alterados com 11961 adições e 1464 exclusões
  1. +24
    -1
      README.md
  2. +26
    -2
      app/assets/css/main.css
  3. +514
    -0
      app/assets/css/theme.css
  4. +45
    -0
      app/components/AppHero.vue
  5. +191
    -0
      app/components/ArticleDetailView.vue
  6. +174
    -0
      app/components/ArticleListView.vue
  7. +126
    -0
      app/components/BackToTop.vue
  8. +47
    -0
      app/components/HeroComparePreview.vue
  9. +112
    -0
      app/components/InviteShareModal.vue
  10. +124
    -0
      app/components/LegalDocument.vue
  11. +108
    -41
      app/components/LoginModal.vue
  12. +71
    -0
      app/components/NewsSidebar.vue
  13. +34
    -0
      app/components/ThemeToggle.vue
  14. +521
    -0
      app/components/WorkDetailModal.vue
  15. +239
    -0
      app/components/WorkHistoryDrawer.vue
  16. +157
    -8
      app/composables/useApi.js
  17. +169
    -0
      app/composables/useArticle.js
  18. +45
    -0
      app/composables/useEarnPoints.js
  19. +98
    -0
      app/composables/useIdPhoto.js
  20. +87
    -0
      app/composables/useInvite.js
  21. +54
    -0
      app/composables/useLegacySeo.js
  22. +98
    -0
      app/composables/useOldPhoto.js
  23. +107
    -0
      app/composables/usePortrait.js
  24. +3
    -4
      app/composables/useUpload.js
  25. +47
    -6
      app/composables/useUser.js
  26. +386
    -42
      app/layouts/default.vue
  27. +4
    -0
      app/middleware/invite.global.js
  28. +22
    -0
      app/middleware/legacy-seo.global.js
  29. +0
    -724
      app/pages/ai-name.vue
  30. +14
    -0
      app/pages/baike/detail/[id].vue
  31. +565
    -138
      app/pages/bill.vue
  32. +14
    -0
      app/pages/course/detail/[id].vue
  33. +231
    -133
      app/pages/draw/anime.vue
  34. +392
    -0
      app/pages/earn-points.vue
  35. +1162
    -0
      app/pages/id-photo.vue
  36. +650
    -67
      app/pages/index.vue
  37. +14
    -0
      app/pages/info/course/[id].vue
  38. +51
    -0
      app/pages/info/course/index.vue
  39. +48
    -0
      app/pages/info/index.vue
  40. +14
    -0
      app/pages/info/new/[id].vue
  41. +51
    -0
      app/pages/info/new/index.vue
  42. +67
    -0
      app/pages/info/tag/[key].vue
  43. +14
    -0
      app/pages/info/what/[id].vue
  44. +51
    -0
      app/pages/info/what/index.vue
  45. +367
    -0
      app/pages/invite/index.vue
  46. +156
    -0
      app/pages/invite/landing.vue
  47. +260
    -0
      app/pages/invite/records.vue
  48. +17
    -0
      app/pages/news/detail/[id].vue
  49. +717
    -0
      app/pages/old-photo.vue
  50. +246
    -0
      app/pages/pic/[id].vue
  51. +98
    -0
      app/pages/pic/index.vue
  52. +874
    -0
      app/pages/portrait-photo.vue
  53. +14
    -0
      app/pages/privacy.vue
  54. +250
    -165
      app/pages/recharge.vue
  55. +14
    -0
      app/pages/terms.vue
  56. +245
    -117
      app/pages/works.vue
  57. +1006
    -0
      app/pages/writing/naming.vue
  58. +112
    -0
      app/utils/nameParse.js
  59. +94
    -0
      app/utils/richtext.js
  60. +3
    -0
      mise.toml
  61. +98
    -4
      nuxt.config.js
  62. BIN
     
  63. +2
    -0
      public/robots.txt
  64. +398
    -0
      scripts/gen-theme.mjs
  65. +19
    -12
      server/api/[...].js

+ 24
- 1
README.md Ver arquivo

@@ -117,10 +117,33 @@ $fetch('/api/user/info')
→ 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>`
- **SEO**:每个页面必须写 `useSeoMeta()` 设置 title / description
- **样式**:统一用 TailwindCSS utility class,避免写裸 CSS
- **样式**:统一用 TailwindCSS utility class,避免写裸 CSS;颜色一律走上面的主题令牌
- **就近一致**:复用 composables / 组件,不引入功能重复的新库
- **端口 6888**:开发服务器,监听所有网口(支持局域网访问)

+ 26
- 2
app/assets/css/main.css Ver arquivo

@@ -3,11 +3,23 @@
/* @nuxt/ui v4 内置 TailwindCSS v4,需在入口显式导入 */
@import "tailwindcss";

/* 主题色令牌(亮色 / 暗色两套取值,由 scripts/gen-theme.mjs 生成) */
@import "./theme.css";

/* 亮暗两色不变的固定色(避免被语义令牌误映射) */
:root,
html.dark {
/* 深色实底:证件照页选中态等「深底 + 白字」的组件用它,暗色下同样成立 */
--c-ink: #172033;
}

/* 全局字体与基础样式 */
body {
font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--c-f3f7fb);
color: var(--c-172033);
}

/* 页面过渡动画 */
@@ -29,9 +41,21 @@ body {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
background: var(--c-cbd5e1);
border-radius: 3px;
}
::-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;
}

+ 514
- 0
app/assets/css/theme.css Ver arquivo

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

+ 45
- 0
app/components/AppHero.vue Ver arquivo

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

+ 191
- 0
app/components/ArticleDetailView.vue Ver arquivo

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

+ 174
- 0
app/components/ArticleListView.vue Ver arquivo

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

+ 126
- 0
app/components/BackToTop.vue Ver arquivo

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

+ 47
- 0
app/components/HeroComparePreview.vue Ver arquivo

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

+ 112
- 0
app/components/InviteShareModal.vue Ver arquivo

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

+ 124
- 0
app/components/LegalDocument.vue Ver arquivo

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

+ 108
- 41
app/components/LoginModal.vue Ver arquivo

@@ -1,13 +1,20 @@
<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="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
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')"
>
<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">
<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>

<!-- Tab 切换 -->
<div class="px-8">
<div class="flex border-b border-slate-200">
<div class="flex border-b border-[var(--c-e2e8f0)]">
<button
:class="[
'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer',
activeTab === 'qr'
? 'text-indigo-600 border-indigo-500'
: 'text-slate-400 border-transparent hover:text-slate-600',
? 'text-[var(--c-0b8cff)] border-[var(--c-0b8cff)]'
: 'text-[var(--c-94a3b8)] border-transparent hover:text-[var(--c-475569)]',
]"
@click="activeTab = 'qr'"
>
@@ -39,8 +47,8 @@
:class="[
'flex-1 pb-3 text-sm font-medium transition-colors border-b-2 cursor-pointer',
activeTab === 'phone'
? 'text-indigo-600 border-indigo-500'
: 'text-slate-400 border-transparent hover:text-slate-600',
? 'text-[var(--c-0b8cff)] border-[var(--c-0b8cff)]'
: 'text-[var(--c-94a3b8)] border-transparent hover:text-[var(--c-475569)]',
]"
@click="activeTab = 'phone'"
>
@@ -54,29 +62,47 @@
<div class="flex flex-col items-center">
<!-- 二维码区域 -->
<div
class="relative w-48 h-48 rounded-xl border border-slate-200 flex items-center justify-center bg-white"
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">
<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" />
<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>
<span class="text-xs text-[var(--c-94a3b8)]">获取二维码...</span>
</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"
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"
>
<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" />
</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
@@ -89,7 +115,7 @@
/>
</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>
</div>
@@ -100,35 +126,35 @@
<div class="space-y-4">
<!-- 手机号 -->
<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
v-model="phoneForm.phone"
type="tel"
maxlength="11"
placeholder="请输入手机号"
class="w-full px-3 py-2.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
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, '')"
/>
</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">
<input
v-model="phoneForm.code"
type="text"
maxlength="6"
placeholder="请输入验证码"
class="flex-1 px-3 py-2.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 transition-colors"
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
:disabled="phoneForm.sendingCode || countdown > 0"
:class="[
'shrink-0 px-4 py-2.5 text-sm font-medium rounded-lg transition-colors cursor-pointer',
countdown > 0
? 'text-slate-400 bg-slate-100 cursor-not-allowed'
: 'text-indigo-600 bg-indigo-50 hover:bg-indigo-100',
? '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"
>
@@ -137,13 +163,18 @@
</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>

<!-- 登录按钮 -->
<button
:disabled="phoneForm.loading || !phoneForm.phone || !phoneForm.code"
class="w-full py-2.5 text-sm font-semibold text-white bg-indigo-500 hover:bg-indigo-600 disabled:bg-slate-300 disabled:cursor-not-allowed rounded-lg transition-colors cursor-pointer"
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"
>
<span v-if="phoneForm.loading" class="inline-flex items-center gap-2">
@@ -160,11 +191,11 @@

<!-- 底部协议 -->
<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>
</div>
</div>
@@ -178,6 +209,8 @@ const emit = defineEmits(['close', 'login-success'])

const { post } = useApi()
const { setLogin } = useUser()
const { inviteCode, withInvite, clearInvite } = useInvite()
const toast = useToast()

// Tab
const activeTab = ref('qr')
@@ -185,31 +218,51 @@ const activeTab = ref('qr')
// ===== 微信扫码 =====
const qrLoading = ref(true)
const qrExpired = ref(false)
const qrFailed = ref(false)
const qrErrorMessage = ref('')
const qrData = ref({ ticket: '', url: '' })
const qrLogo = '' // 可替换为 logo URL
let qrTimer = null

// 获取二维码
async function refreshQR() {
clearInterval(qrTimer)
qrLoading.value = true
qrExpired.value = false
qrFailed.value = false
qrErrorMessage.value = ''
qrData.value = { ticket: '', url: '' }

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
qrLoading.value = false
startPolling()
} else {
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
qrFailed.value = false
qrExpired.value = true
}

@@ -224,11 +277,12 @@ function startPolling() {
handleLoginSuccess(res.data)
} else if (res.code === 1010) {
// 二维码过期
clearInterval(qrTimer)
qrExpired.value = true
markQrExpired()
}
} catch {
// 轮询失败不中断
} catch (error) {
// useApi 会把非 0 业务码抛出;1010 需要在异常分支识别为真正过期。
if (Number(error?.code) === 1010) markQrExpired()
// 其他短暂轮询失败不中断
}
}, 1000)
}
@@ -240,6 +294,7 @@ const phoneForm = reactive({
sendingCode: false,
loading: false,
error: '',
devCode: '', // 开发环境后端直接返回的验证码,仅用于提示
})

const countdown = ref(0)
@@ -260,6 +315,11 @@ async function sendCode() {
type: 3,
})
if (res.code === 0) {
// 开发/测试环境后端不发短信,直接把验证码返回,这里自动回填方便调试
if (res.data?.code) {
phoneForm.code = String(res.data.code)
phoneForm.devCode = String(res.data.code)
}
countdown.value = 60
countdownTimer = setInterval(() => {
countdown.value--
@@ -284,10 +344,10 @@ async function loginByPhone() {
phoneForm.loading = true

try {
const res = await post('/user/login_phone', {
const res = await post('/user/login_phone', withInvite({
phone: phoneForm.phone,
code: phoneForm.code,
})
}))
if (res.code === 0 && res.data?.token) {
handleLoginSuccess(res.data)
} else {
@@ -302,7 +362,14 @@ async function loginByPhone() {

// ===== 公共 =====
function handleLoginSuccess(data) {
const invited = Boolean(inviteCode.value)
setLogin(data)
clearInvite()
toast.add({
title: invited && data.is_register ? '登录并领取成功' : '登录成功',
description: invited && data.is_register ? '邀请奖励点数已自动到账。' : '',
color: 'success',
})
emit('login-success', data)
}



+ 71
- 0
app/components/NewsSidebar.vue Ver arquivo

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

+ 34
- 0
app/components/ThemeToggle.vue Ver arquivo

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

+ 521
- 0
app/components/WorkDetailModal.vue Ver arquivo

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

+ 239
- 0
app/components/WorkHistoryDrawer.vue Ver arquivo

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

+ 157
- 8
app/composables/useApi.js Ver arquivo

@@ -15,17 +15,56 @@
*/
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() {
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: '/' })

/**
* 登录态从 useState 共享状态读取(与 useUser 同源)。
* 不能只依赖这里的 useCookie ref:页面若在登录前就创建了 useApi,
* 该 ref 感知不到后续登录写入,会导致请求不带 token → 1009。
*/
const tokenState = useState('auth_token', () => tokenCookie.value || '')
const openIdState = useState('auth_open_id', () => openIdCookie.value || '')

// mac 缺失时自动生成
if (import.meta.client && !macCookie.value) {
macCookie.value = generateUUID()
}

/**
* mac 兜底值。
* SSR(含搜索引擎爬虫首访)时还没有 mac Cookie,而后端 base.js 要求 mac 必填,
* 缺失会直接返回「mac必填」,导致服务端渲染取不到数据、SEO 抓不到内容。
*/
const SSR_MAC = 'ssr-render'

function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
@@ -50,8 +89,13 @@ export function useApi() {
async function request(path, 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 { timeStr, nonce } = getNonce()
@@ -62,14 +106,15 @@ export function useApi() {
timestr: timeStr,
nonce,
}
const token = tokenCookie.value
// 每次请求时读取最新登录态(共享 state 优先,兼容 SSR 时的 cookie)
const token = tokenState.value || tokenCookie.value || ''
if (token) reqHeaders.token = token

// Body 公共参数(对齐 V2 data 注入)
const commonParams = {
token: token || '',
uid: openIdCookie.value || '',
mac: macCookie.value || '',
uid: openIdState.value || openIdCookie.value || '',
mac: macCookie.value || SSR_MAC,
base_timestamp: timestamp,
client: 1,
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) {
if (result.code === 1009) {
// 登录失效 → 触发登录弹窗
// 登录失效 → 清理登录态并触发登录弹窗
if (import.meta.client) {
const { logout } = useUser()
const { logout, showLogin } = useUser()
logout()
const showLogin = useState('showLogin', () => false)
showLogin.value = true
}
const err = new Error(result.msg || '登录已失效,请重新登录')
@@ -144,6 +291,8 @@ export function useApi() {
get: (path, params) => request(path, { method: 'GET', params }),
/** POST 请求 (x-www-form-urlencoded) */
post: (path, body) => request(path, { method: 'POST', body }),
/** POST SSE 流式请求 */
postStream,
/** 通用请求 */
request,
}


+ 169
- 0
app/composables/useArticle.js Ver arquivo

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

+ 45
- 0
app/composables/useEarnPoints.js Ver arquivo

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

+ 98
- 0
app/composables/useIdPhoto.js Ver arquivo

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

+ 87
- 0
app/composables/useInvite.js Ver arquivo

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


+ 54
- 0
app/composables/useLegacySeo.js Ver arquivo

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

+ 98
- 0
app/composables/useOldPhoto.js Ver arquivo

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

+ 107
- 0
app/composables/usePortrait.js Ver arquivo

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

+ 3
- 4
app/composables/useUpload.js Ver arquivo

@@ -34,10 +34,8 @@ export function useUpload() {
const { timeStr, nonce } = getNonce()
const formData = new FormData()

// 文件字段(最先添加)
formData.append('file', file)

// 公共参数(对齐 V2 upload.js)
// ThinkJS 2 会在控制器 __before() 中先校验公共参数,因此这些字段
// 必须位于文件字段之前,否则 multipart 解析时会误报“client必填”。
formData.append('token', tokenCookie.value || '')
formData.append('uid', openIdCookie.value || '')
formData.append('mac', macCookie.value || '')
@@ -48,6 +46,7 @@ export function useUpload() {
formData.append('version', '1.0.0')
formData.append('version_code', '1')
formData.append('upload_type', '1')
formData.append('file', file)

// 走 /api 代理,对齐 V2
const url = '/api/common/uploadpic'


+ 47
- 6
app/composables/useUser.js Ver arquivo

@@ -5,11 +5,25 @@
* Token 持久化:Cookie(SSR 安全,Nuxt useCookie 自动同步服务端/客户端)
*/
export function useUser() {
const token = useCookie('token', {
const tokenCookie = useCookie('token', {
maxAge: 7 * 24 * 3600,
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', () => ({
user_id: null,
user_name: '',
@@ -20,7 +34,12 @@ export function useUser() {
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)
@@ -29,7 +48,7 @@ export function useUser() {
* 设置登录态
*/
function setLogin(data) {
token.value = data.token
setToken(data.token)
userInfo.value = {
user_id: data.user_id,
user_name: data.user_name || data.nickname || '',
@@ -39,10 +58,11 @@ export function useUser() {
vip_type: data.vip_type || 0,
balance: data.balance || 0,
}
// 同步写入 open_id Cookie(供 useApi 读取 uid)
// 同步写入 open_id(共享状态 + Cookie,供 useApi 读取 uid)
if (data.open_id) {
const openIdCookie = useCookie('open_id', { path: '/' })
openIdCookie.value = data.open_id
useState('auth_open_id', () => '').value = data.open_id
}
}

@@ -50,7 +70,7 @@ export function useUser() {
* 退出登录
*/
function logout() {
token.value = null
setToken('')
userInfo.value = {
user_id: null,
user_name: '',
@@ -60,9 +80,10 @@ export function useUser() {
vip_type: 0,
balance: 0,
}
// 清除 open_id Cookie
// 清除 open_id
const openIdCookie = useCookie('open_id', { path: '/' })
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,自动刷新
*/
@@ -110,12 +149,14 @@ export function useUser() {

return {
token,
setToken,
userInfo,
isLogin,
showLogin,
setLogin,
logout,
refreshUserInfo,
refreshBalance,
handleAuthError,
init,
}


+ 386
- 42
app/layouts/default.vue Ver arquivo

@@ -1,72 +1,292 @@
<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>
<span class="text-lg font-semibold text-slate-800 hidden sm:block">奇想宇宙</span>
</NuxtLink>

<!-- 导航链接 -->
<nav class="hidden md:flex items-center gap-1">
<NuxtLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="px-3 py-2 text-sm rounded-lg text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 transition-colors duration-200"
active-class="text-indigo-600 bg-indigo-50 font-medium"
>
{{ item.label }}
</NuxtLink>
<!-- 主导航(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>

<!-- 右侧操作 -->
<div class="flex items-center gap-3">
<div class="ml-auto flex items-center gap-2 sm:gap-3">
<!-- 亮色 / 暗色切换 -->
<ThemeToggle />

<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
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>
<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>
</template>

<button
v-else
class="px-4 py-2 text-sm font-medium text-white bg-indigo-500 hover:bg-indigo-600 rounded-lg transition-colors duration-200 cursor-pointer"
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"
>
登录
</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>

<!-- 移动端下拉导航 -->
<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>

<!-- 主体内容 -->
<!-- ==================== 主体内容 ==================== -->
<main class="flex-1">
<slot />
</main>

<!-- 页脚 -->
<footer class="border-t border-slate-200 bg-white py-8 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm text-slate-400">
<p>&copy; {{ new Date().getFullYear() }} 奇想宇宙 AIonline. All rights reserved.</p>
<!-- ==================== 页脚(白色系、紧凑,二维码与内容同行) ==================== -->
<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>&copy; {{ new Date().getFullYear() }} AI在线</span>
</div>
</div>
</footer>

<!-- 全局回到顶部(所有页面共用,滚动超过一屏才出现) -->
<BackToTop />

<!-- 登录弹窗 -->
<LoginModal
v-if="showLogin"
@@ -77,17 +297,141 @@
</template>

<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 = [
{ 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) {
setLogin(data)
showLogin.value = false
refreshBalance()
}
</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>

+ 4
- 0
app/middleware/invite.global.js Ver arquivo

@@ -0,0 +1,4 @@
export default defineNuxtRouteMiddleware((to) => {
const { captureInvite } = useInvite()
captureInvite(to.query)
})

+ 22
- 0
app/middleware/legacy-seo.global.js Ver arquivo

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

+ 0
- 724
app/pages/ai-name.vue Ver arquivo

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

+ 14
- 0
app/pages/baike/detail/[id].vue Ver arquivo

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

+ 565
- 138
app/pages/bill.vue Ver arquivo

@@ -1,174 +1,601 @@
<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 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 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 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>
</template>

<script setup>
useSeoMeta({
title: '账单明细 - 奇想宇宙',
description: '查看账户点数变动明细',
title: '点数账单 - AI在线',
description: '查看点数充值、消费、奖励与余额变动明细',
})

const { isLogin } = useUser()
const { userInfo, isLogin, showLogin } = useUser()
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 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) {
page.value = 1
bills.value = []
list.value = []
total.value = 0
}

loading.value = true
loadError.value = ''

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

<style scoped>
.bill-tabs {
scrollbar-width: none;
}

.bill-tabs::-webkit-scrollbar {
display: none;
}
</style>

+ 14
- 0
app/pages/course/detail/[id].vue Ver arquivo

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

app/pages/anime-avatar.vue → app/pages/draw/anime.vue Ver arquivo

@@ -1,38 +1,45 @@
<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 ========== -->
<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) ===== -->
<div class="lg:col-span-7 space-y-5 pb-24 lg:pb-0">
<!-- 1. 参考照片 Upload Card -->
<div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]">
<div class="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="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>
<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>
<!-- 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">
<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" />
<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 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 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 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>
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileChange" />
<!-- Tips -->
<div class="mt-3 flex gap-2.5">
<span class="px-3 py-1 rounded-full text-[11px] font-bold text-[#526174] bg-[#f1f5fa]">正面</span>
<span class="px-3 py-1 rounded-full text-[11px] font-bold text-[#526174] bg-[#f1f5fa]">脸部清晰</span>
<span class="px-3 py-1 rounded-full text-[11px] font-bold text-[#526174] bg-[#f1f5fa]">避免遮挡</span>
<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>

<!-- 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">
<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 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>
<template v-else>
<!-- 风格网格:移动 3 列 / 桌面 4 列,默认 2 行折叠 -->
@@ -88,19 +95,19 @@
<button v-for="s in visibleStyleList" :key="s.value" type="button"
class="p-2 rounded-2xl border-2 transition-all text-center"
:class="form.img2img_style === String(s.value)
? 'bg-[#eef7ff] border-[#1f8cff]'
: 'bg-[#f6f9fd] border-transparent hover:border-[#e5edf7]'"
? 'bg-[var(--c-eef7ff)] border-[var(--c-1f8cff)]'
: 'bg-[var(--c-f6f9fd)] border-transparent hover:border-[var(--c-e5edf7)]'"
@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" />
<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 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>
</div>
<!-- 展开/收起按钮 -->
<button v-if="hasMoreStyles" type="button"
class="mt-3 w-full h-10 rounded-xl border border-[#e5edf7] text-[#1f8cff] text-sm font-bold hover:bg-[#eef7ff] transition-colors flex items-center justify-center gap-1"
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">
<span>{{ styleExpanded ? '收起' : '展开全部' }}</span>
<UIcon :name="styleExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="size-4" />
@@ -109,60 +116,66 @@
</div>

<!-- 3. 自由度 -->
<div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]">
<div class="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>
<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>
<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>
<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>
</div>
</div>

<!-- 4. 画面偏好 -->
<div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]">
<div class="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">
<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 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="例如:浅色背景、清爽、微笑"
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 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="例如:模糊、变形、低清晰度"
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>

<!-- 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">
<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 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>

<!-- 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="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>
<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">
<UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" />
<span>{{ submitting ? '生成中...' : submitText }}</span>
@@ -172,61 +185,74 @@

<!-- ===== RIGHT: Result (col-span-5, sticky) ===== -->
<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 -->
<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>

<!-- 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">
<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 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>

<!-- 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" />
<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>
</div>

<!-- Success -->
<div v-if="resultState.success" class="space-y-4">
<div class="bg-white rounded-2xl p-5 lg:p-6 shadow-sm">
<div class="text-base font-bold text-[#172033] mb-4">效果对比</div>
<div class="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>
<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 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 class="mt-5 flex gap-3">
<button type="button"
class="flex-1 h-12 rounded-full border-2 border-[#1f8cff] text-[#1f8cff] font-bold hover:bg-[#eef7ff] transition-colors flex items-center justify-center"
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">
<UIcon name="i-lucide-split-square-horizontal" class="size-4 mr-1.5" />
对比查看
</button>
<button type="button"
class="flex-1 h-12 rounded-full bg-[#1f8cff] hover:bg-[#1979e6] text-white font-bold text-base shadow-[0_8px_20px_rgba(31,140,255,0.2)] transition-colors flex items-center justify-center"
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">
<UIcon name="i-lucide-download" class="size-4 mr-1.5" />
下载头像
@@ -235,7 +261,7 @@
</div>
<div class="flex gap-3">
<button type="button"
class="flex-1 h-12 rounded-full border-2 border-[#1f8cff] text-[#1f8cff] font-bold hover:bg-[#eef7ff] transition-colors"
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>
</div>
</div>
@@ -245,16 +271,16 @@
</div>

<!-- ========== Mobile Bottom Bar ========== -->
<div class="lg:hidden fixed left-0 right-0 bottom-0 z-50 bg-white/95 backdrop-blur shadow-[0_-10px_30px_rgba(32,56,92,0.1)]"
<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))">
<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 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">
<UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" />
{{ submitting ? '生成中...' : submitText }}
@@ -262,18 +288,22 @@
</div>
</div>

<!-- ========== 历史记录抽屉 + 详情弹窗 ========== -->
<WorkHistoryDrawer v-model="historyOpen" type="anime" @select="openHistoryItem" />
<WorkDetailModal v-model="detailOpen" :work="currentWork" />

<!-- ========== Compare Modal (大图滑块对比,支持 demo 和生成结果) ========== -->
<Teleport to="body">
<Transition name="modal">
<div v-if="compareVisible" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div class="absolute inset-0 bg-[#0f172a]/60" @click="closeCompare" />
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h3 class="text-base font-extrabold text-[#172033]">{{ compareTarget === 'result' ? '生成结果对比' : '效果对比' }}</h3>
<button class="text-[#a0aabb] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100" @click="closeCompare">×</button>
<div 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 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">
<!-- 动漫图(底层,全尺寸) -->
<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"
: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>
</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 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>
@@ -303,6 +333,15 @@ import { computed, reactive, ref, onMounted, onUnmounted } from 'vue'

const _api = useApi()
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 router = useRouter()
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 genResultUrl = ref('')
const resultImageFailed = ref(false)

let pollTimer = null
let countTimer = null
@@ -468,6 +508,8 @@ async function loadBalance() {
const r = await api.post('/user/getbalance', {})
if (r.code === 0 && r.data) balance.points = r.data.points || r.data.point_balance || 0
} catch (e) { console.error('[anime-avatar] loadBalance failed:', e) }
// 同步 header 显示的点数
user.refreshBalance()
}

onMounted(() => {
@@ -478,6 +520,16 @@ onMounted(() => {
})
onUnmounted(() => clearPoll())

// ==================== 历史记录 / 详情 ====================
const historyOpen = ref(false)
const detailOpen = ref(false)
const currentWork = ref({})

function openHistoryItem(item) {
currentWork.value = item
detailOpen.value = true
}

// ==================== Compare Modal ====================
const compareVisible = ref(false)
const comparePercent = ref(50)
@@ -499,7 +551,14 @@ function openCompare() {
compareVisible.value = true
}
function openResultCompare() {
if (!previewImage.value || !genResultUrl.value) return
if (!previewImage.value) {
showToast('原图暂不可用,请重新上传')
return
}
if (!genResultUrl.value || resultImageFailed.value) {
showToast('结果图片暂不可用,请稍后重试')
return
}
comparePercent.value = 50
compareTarget.value = 'result'
compareVisible.value = true
@@ -533,6 +592,31 @@ function updateComparePercent(e) {
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 ====================
function clearPoll() {
clearTimeout(pollTimer)
@@ -548,8 +632,20 @@ async function startPoll() {
const d = r.data || {}
const s = Number(d.is_suc)
if (s > 0) {
genResultUrl.value = d.answer || d.result_url || d.url || ''
resultState.value = { visible: true, loading: false, error: false, success: true, errorMsg: '' }
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()
loadBalance()
} else if (s < 0) {
@@ -573,6 +669,7 @@ async function handleSubmit() {
submitting.value = true
resultState.value = { visible: true, loading: true, error: false, success: false, errorMsg: '' }
genResultUrl.value = ''
resultImageFailed.value = false

try {
const params = { ...form, imgs: form.imgs.slice(0, 1).join(',') }
@@ -610,7 +707,10 @@ async function handleSubmit() {
}

async function downloadResult() {
if (!genResultUrl.value) return
if (!genResultUrl.value || resultImageFailed.value) {
showToast('结果图片暂不可用,请稍后重试')
return
}
try {
const r = await fetch(genResultUrl.value)
const b = await r.blob()
@@ -625,10 +725,8 @@ async function downloadResult() {
}

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>

<style scoped>

+ 392
- 0
app/pages/earn-points.vue Ver arquivo

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

+ 1162
- 0
app/pages/id-photo.vue
Diferenças do arquivo suprimidas por serem muito extensas
Ver arquivo


+ 650
- 67
app/pages/index.vue Ver arquivo

@@ -1,57 +1,461 @@
<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
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>
</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>
</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>
</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>
</section>
</div>
@@ -59,47 +463,226 @@

<script setup>
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 = [
{
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: '#',
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>

<style scoped>
/* Tailwind 没有 h-13,这里补一个统一的按钮高度 */
.h-13 {
height: 3.25rem;
}
</style>

+ 14
- 0
app/pages/info/course/[id].vue Ver arquivo

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

+ 51
- 0
app/pages/info/course/index.vue Ver arquivo

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

+ 48
- 0
app/pages/info/index.vue Ver arquivo

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

+ 14
- 0
app/pages/info/new/[id].vue Ver arquivo

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

+ 51
- 0
app/pages/info/new/index.vue Ver arquivo

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

+ 67
- 0
app/pages/info/tag/[key].vue Ver arquivo

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

+ 14
- 0
app/pages/info/what/[id].vue Ver arquivo

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

+ 51
- 0
app/pages/info/what/index.vue Ver arquivo

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

+ 367
- 0
app/pages/invite/index.vue Ver arquivo

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

+ 156
- 0
app/pages/invite/landing.vue Ver arquivo

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

+ 260
- 0
app/pages/invite/records.vue Ver arquivo

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

+ 17
- 0
app/pages/news/detail/[id].vue Ver arquivo

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

+ 717
- 0
app/pages/old-photo.vue Ver arquivo

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

+ 246
- 0
app/pages/pic/[id].vue Ver arquivo

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

+ 98
- 0
app/pages/pic/index.vue Ver arquivo

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

+ 874
- 0
app/pages/portrait-photo.vue Ver arquivo

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

+ 14
- 0
app/pages/privacy.vue Ver arquivo

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

+ 250
- 165
app/pages/recharge.vue Ver arquivo

@@ -1,108 +1,172 @@
<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>

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

<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 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>
@@ -113,130 +177,151 @@
import VueQr from 'vue-qr'

useSeoMeta({
title: '充值中心 - 奇想宇宙',
description: '选择套餐,为AI创作充值点数',
title: '点数充值 - AI在线',
description: '选择套餐充值点数,点数长期有效、多端通用',
})

const { userInfo, isLogin } = useUser()
const { userInfo, isLogin, showLogin, refreshBalance } = useUser()
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 orderNo = ref('')
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 {
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 {
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() {
if (!selectedId.value) return
if (!current.value) return
if (!isLogin.value) {
showLogin.value = true
return
}

payState.value = 'loading'
isPaying.value = true

codeUrl.value = ''
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'
startPolling()
} else {
payState.value = 'error'
showMsg(res.msg || 'PC 端支付暂不可用')
}
} catch {
} catch (e) {
payState.value = 'error'
showMsg(e.message || '创建订单失败')
}
}

// 轮询支付结果(对齐老 PC:每 2 秒轮询 paysuccess)
// 轮询订单状态:服务端会主动查微信并流转订单 + 幂等入账
function startPolling() {
clearInterval(pollingTimer)
pollingTimer = setInterval(async () => {
if (!orderNo.value) return
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)
payState.value = 'success'
// 刷新余额
await refreshUserInfo()
balance.points = d.balance ?? balance.points
refreshBalance() // 同步 header 点数
}
} catch {
// 轮询失败不中断
// 单次失败不中断轮询
}
}, 2000)
}

// 取消支付
function cancelPay() {
clearInterval(pollingTimer)
isPaying.value = false
payState.value = 'idle'
codeUrl.value = ''
orderNo.value = ''
}

// 支付成功完成
function finishPaySuccess() {
clearInterval(pollingTimer)
isPaying.value = false
payState.value = 'idle'
codeUrl.value = ''
orderNo.value = ''
selectedId.value = null
}

// 刷新用户余额
async function refreshUserInfo() {
try {
const res = await post('/user/getuserinfo')
if (res.code === 0 && res.data) {
userInfo.value.balance = res.data.balance || res.data.point || 0
}
} catch {
// 忽略
}
function finishPay() {
cancelPay()
loadBalance()
}

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>

+ 14
- 0
app/pages/terms.vue Ver arquivo

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

+ 245
- 117
app/pages/works.vue Ver arquivo

@@ -1,160 +1,288 @@
<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
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 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 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 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>
</template>

<script setup>
import dayjs from 'dayjs'

useSeoMeta({
title: '我的作品 - 奇想宇宙',
description: '查看AI创作的所有作品',
title: '我的作品 - AI在线',
description: '查看 AI 取名、动漫头像、证件照等全部创作记录',
})

const { isLogin } = useUser()
const { isLogin, showLogin } = useUser()
const { post } = useApi()
const toast = useToast()

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 loading = ref(true)
const works = ref([])
const list = ref([])
const total = ref(0)
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
if (reset) page.value = 1
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 {
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() {
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>

+ 1006
- 0
app/pages/writing/naming.vue
Diferenças do arquivo suprimidas por serem muito extensas
Ver arquivo


+ 112
- 0
app/utils/nameParse.js Ver arquivo

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

+ 94
- 0
app/utils/richtext.js Ver arquivo

@@ -0,0 +1,94 @@
/**
* 后台富文本清洗(协议、公告等静态内容)
*
* 背景:html 表里的正文是 Word / 旧编辑器导出的,直接渲染有两类问题:
* 1. 大量 `<p><span>&nbsp;</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 是否只有空白内容(空格、&nbsp;、<br>、空标签) */
function isBlankFragment(html) {
const text = String(html || '')
.replace(/<br\s*\/?>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;|&#160;|\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

+ 3
- 0
mise.toml Ver arquivo

@@ -0,0 +1,3 @@
# Nuxt 4.5 强制要求 Node >= 22.19.0(见 README「前置条件」)
[tools]
node = "22.19.0"

+ 98
- 4
nuxt.config.js Ver arquivo

@@ -1,3 +1,5 @@
import { fileURLToPath } from 'node:url'

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
@@ -9,6 +11,18 @@ export default defineNuxtConfig({

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)
ui: {
fonts: false,
@@ -19,22 +33,102 @@ export default defineNuxtConfig({
public: {
// ThinkJS ai_api 地址,按环境覆盖
apiBase: process.env.API_BASE || 'http://192.168.31.168:16888',
// 邀请链接正式域名;未配置时自动使用当前访问域名。
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || '',
},
},

// SSR / SEO
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: {
head: {
title: '奇想宇宙',
titleTemplate: '%s',
title: 'AI在线网站-你的AI伙伴让创作更简单',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: 'AI 问答、AI 绘画、AI 音乐,探索无限可能' },
{ name: 'keywords', content: 'AI,人工智能,Midjourney,AI绘画,AI音乐,AI问答' },
{ 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' }],
},
},
})


+ 2
- 0
public/robots.txt Ver arquivo

@@ -0,0 +1,2 @@
User-agent: *
Allow:/

+ 398
- 0
scripts/gen-theme.mjs Ver arquivo

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

+ 19
- 12
server/api/[...].js Ver arquivo

@@ -10,6 +10,8 @@
*
* 环境变量 API_BASE 可覆盖目标地址(生产环境指向 https://api.aionline.cc)
*/
import { proxyRequest, sendWebResponse } from 'h3'

export default defineEventHandler(async (event) => {
const apiBase = process.env.API_BASE || 'http://192.168.31.168:16888'

@@ -33,14 +35,17 @@ export default defineEventHandler(async (event) => {
if (contentType) {
headers['content-type'] = contentType
}
const isMultipart = Boolean(contentType?.includes('multipart/form-data'))

// 透传签名的 header(nonce / timestr / token)
const clientNonce = event.headers.get('nonce')
const clientTimestr = event.headers.get('timestr')
const clientToken = event.headers.get('token')
const clientAccept = event.headers.get('accept')
if (clientNonce) headers.nonce = clientNonce
if (clientTimestr) headers.timestr = clientTimestr
if (clientToken) headers.token = clientToken
if (clientAccept) headers.accept = clientAccept

const fetchOptions = { method, headers }

@@ -49,26 +54,28 @@ export default defineEventHandler(async (event) => {
const queryStr = new URLSearchParams(query).toString()
const fullUrl = queryStr ? url + '?' + queryStr : url

// Multipart 必须直接转发原始请求流。先 readRawBody() 在部分 Chromium /
// Nitro 组合下会一直等待流结束,导致上传请求既不到后端也不返回。
if (isMultipart) {
return proxyRequest(event, fullUrl, { headers })
}

// POST: 请求体
if (method === 'POST') {
const body = await readBody(event)
if (body && typeof body === 'object') {
// 如果是 FormData 上传(multipart),直接透传原始 body
if (contentType && contentType.includes('multipart/form-data')) {
// multipart 请求的 body 需要特殊处理,直接用原始 buffer
const rawBody = await readRawBody(event)
if (rawBody) {
fetchOptions.body = rawBody
// 保留原始 boundary
fetchOptions.headers = { ...fetchOptions.headers, 'content-type': contentType }
}
} else {
fetchOptions.body = new URLSearchParams(body).toString()
}
fetchOptions.body = new URLSearchParams(body).toString()
}
}

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)
} catch (error) {
console.error('[Server Proxy Error]', url, error.message)


Carregando…
Cancelar
Salvar