Compare commits

...
46 Commits
Author SHA1 Message Date
chiyiandDevin f3b9e570c2 feat(models): 对齐 vivid 新改动——模型删除改软删,重导不丢计数/定价
- model_configs 加 deleted_at(gorm 软删),List/Get 自动过滤,
  GetAny/Unscoped 用于复活与孤儿清理路径
- 渠道同步/删渠道/管理端删除模型的孤儿清理全部走软删
- 导入时若命中软删行:UpdateAny + deleted_at=NULL 复活,
  generation_count 与原定价保留(vivid a42f32c 同语义)
- provider 时代残留仍物理删除(DeleteHard),不留软删垃圾
- OrphanModelIDs 跳过已软删行,避免重复清扫
- btn-primary 禁用态改浅灰填充(vivid d636105)

核对过已等价的:vivid 僵尸任务兜底(我们的 ResumeStaleJob+
PurgeStale+MarkRefunded 同语义)、产物 MIME 魔数嗅探、
retention 按创建时间、渠道同步弹窗(开关+别名)均已覆盖。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 21:48:45 +08:00
chiyiandDevin 76510d34c9 feat(protocol): /v1 协议去除 fixed 时长模式,统一 options/range
- /v1/models:单值时长也输出 {"mode":"options","options":[N]},不再发 fixed
- 导入/同步校验只接受 options / range,fixed 分支删除,报错「时长模式仅支持 options / range」
- 渠道模型预览的 fixed 展示分支去掉
- 内部存储不变(durations 数组),历史模型与下单逻辑不受影响

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 21:16:22 +08:00
chiyiandDevin 1753e03898 docs: 重写 README 界面预览,换新 UI 的 1920×1080 截图
- hero 图换成新首页;预览网格改为画图台/概览/模型/渠道/用户/日志六张
- 删除账号池时代的 accounts.png,新增 channels.png、users.png
- 去掉"深/浅双主题"描述(现为固定浅色),用户端补"无限画布"
- README.en.md 补上此前缺失的截图区,与中文版对齐

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:32:31 +08:00
chiyiandDevin d37ca69f77 fix(ui): 全局 rescue 覆盖导致操作按钮一直是灰底
.theme-text .act 全局规则强制 surface-2 灰底,优先级高于
组件 scoped 样式。摘出 .act 单独给白底灰边,删除按钮白底粉边。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:25:02 +08:00
chiyiandDevin 0373aa2d03 fix(ui): 后台卡片统一纯白——半透明底叠米色页底发灰
.card 原用 90% 透明度的 --surface,叠在暖色页底上让
统计卡、工具栏、表格看起来都是灰的。改 --surface-solid 纯白。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:15:37 +08:00
chiyiandDevin e8e5527e63 style(ui): 操作按钮白底灰边,危险按钮悬停淡红
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:12:31 +08:00
chiyiandDevin 58ee41a4f8 fix(users): 操作列加宽防挤压 + 吸底格补行分隔线
- 操作列 w-24→w-36,三个按钮不再贴边被切
- 吸底单元格补 inset 底边框,行分隔线延伸到最右缘
- 吸底表头补同款底边框

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:10:44 +08:00
chiyiandDevin c60ba294e1 style(ui): 表头灰带去除,表格统一白底
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:07:00 +08:00
chiyiandDevin edd5253529 style(ui): 操作按钮改幽灵样式——平时仅图标,悬停出底
- .act/.op 去底色边框,悬停浅灰底,危险按钮悬停淡红底+红字
- 用户/模型/兑换码/违禁词/并发分组/渠道六页统一

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:06:06 +08:00
chiyiandDevin a9640ff059 feat(users): 彩色头像 + 操作列吸底 + 滚轮横滚
- 头像按用户名哈希出淡彩底色(9 色轮换),不再全灰
- 操作列 position:sticky 吸在右缘,横向滚动时按钮始终可见
- 鼠标纵向滚轮映射为表格横向滚动(滚到尽头放行给页面)
- 最小宽收窄 1280→1150,1440 视口基本放下

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:59:37 +08:00
chiyiandDevin e509584a77 fix(users): 表格列宽放宽——内容不截断,超出横向滚动
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:54:05 +08:00
chiyiandDevin ab9b63f5cb feat(ui): 用户表重构 + 全站表头加高加大
- 用户列:头像块 + 用户名/邮箱/备注三行合并(vivid 同款)
- 最近登录与登录 IP 合并为两行单元格,注册时间单行日期
- 15 列平铺收敛为 12 列,最小宽 1500→1060,1440 视口免横滚
- 全站表头 11px→13px、上下留白 16px,行更高更舒展
- 表体基线字号统一 13px
- 账户信息卡片标签与值间距加大

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:52:30 +08:00
chiyiandDevin aeb94dd11c fix(settings): 账户信息标签与值间距 4px → 9px,加大值字号与行高
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:46:43 +08:00
chiyiandDevin b700294ad3 style(ui): 表格配色收敛到全局墨色基调
- 统计卡:大数字统一墨色、标签统一次要灰,去掉绿/红/橙彩虹数字
- 所有语义色文字 300/200 浅色加深为 600/500(淡底深字,适配亮色主题)
- 徽章/胶囊 ring 与 border 统一为 500/25 细描边
- 滑动开关开态由绿改墨黑(对齐 vivid switch.on)
- .fp-* 徽章改为 vivid 同款淡底深字
- 跳过媒体覆盖层胶囊、暗色代码块等本就属于暗底的元素

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:44:57 +08:00
chiyiandDevin df6ef5ad22 style(ui): 全站空占位统一为图标块 + 文案
- 新增全局 .empty/.empty-ico/.empty-text/.empty-sub
- 渠道、并发分组、违禁词、触发记录、兑换码、订单、邀请日志、
  文档模型表、作品管理的空态全部改为图标块样式
- 渠道表单状态已另改滑动开关

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:37:27 +08:00
chiyiandDevin 682960de26 fix(channel): 渠道表单状态改为滑动开关,一眼可辨可切换
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:33:09 +08:00
chiyiandDevin 71c0371c31 style(ui): 数据表格统一细节——表头规格、选中行高亮
- 全站表格表头统一为 11px/700/0.07em 次要色,上下 11px
- 多选表格(用户/违禁词/兑换码)选中行加灰底 + 左缘黑条高亮
- 未显式设色的 td 默认次要文本色

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:32:22 +08:00
chiyiandDevin c78b01d988 feat(ui): 设置页重设计 + 全站表格配色统一为亮色变量
- 设置页:移除暗色残留,统一页头/卡片/图标块/按钮样式
- 模型管理:照 vivid 重排——模型名等宽显示,子行展示渠道名+权重,
  定价胶囊「档位+普通价+代理价」,配置列统一灰色等宽标签,
  操作列加宽修复删除按钮被挤
- 后端 ModelsView 返回每个模型绑定的渠道名列表(channel_models JOIN)
- 全站 scoped 样式里的硬编码白值(rgb(255 255 255/white)批量替换为
  主题变量,修复亮色模式下表头/开关/操作按钮不可见(如渠道表头)
- 关于页:联系卡片统一等高布局,链接/非链接卡片对齐一致

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:28:47 +08:00
chiyiandDevin 6c6e5add03 feat(invite): 邀请好友页重设计
页头与站点统一;深色邀请卡(链接+复制按钮、虚线邀请码、大号统计);
三步流程说明;邀请记录表保持不变。修复标题发灰、复制按钮文字不可见。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:04:21 +08:00
chiyiandDevin 971d47c661 fix(playground): 画图页选项选中态不可见 — 补 chip-on 样式,加深模式切换容器
chip-on 一直没有样式,导致比例/画质/时长/张数选中项反而是白底;
补墨黑底白字选中态。生图/生视频切换器容器加深,选中项加描边与阴影。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:03:02 +08:00
chiyiandDevin c49125b38f feat(user): 创作记录/生成日志/订单页统一页头与工具栏
页头改为标题+说明+圆形刷新按钮;类型/状态筛选改下拉;
全选/下载/删除/去画图等操作收进工具栏右侧,样式与后台一致。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:01:34 +08:00
chiyiandDevin af7e09cf21 feat(admin): 刷新改为独立的圆形图标按钮(42px),主操作按钮单独一行
- 12 个后台页的刷新统一为 .refresh-btn,固定在页头最右,悬停旋转
- 渠道管理「添加渠道」、并发分组「新增分组」移到页头下方单独一行
- 渠道空状态文案同步

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:59:57 +08:00
chiyiandDevin 0fe1706ef9 fix(admin): 违禁词页添加/批量导入按钮靠右排列
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:56:36 +08:00
chiyiandDevin 3d5be149e3 fix(admin): 工具栏按钮尺寸统一(同宽同高同字重),违禁词页添加按钮贴近输入框
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:55:13 +08:00
chiyiandDevin 98d1124f66 feat(admin): 所有后台页统一页头(标题+说明+刷新+主操作),新默认 logo 与站名字体
- 13 个后台页统一 page-head:左标题与一行说明,右侧刷新与主操作,刷新位置一致
- 渠道/并发/违禁词/订单/邀请日志/作品/首页内容/系统配置补齐或改写标题
- 违禁词与订单页改用统一 toolbar,订单筛选胶囊改下拉
- 站名字体改站酷快乐体(英文数字 5KB 子集 + 汉字按需加载)并加粗描边
- 默认 logo 重画:墨黑圆角方块 + 白色星芒 + 暖橙点,favicon 同步

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:53:26 +08:00
chiyiandDevin d49207b8f2 feat(brand): 站名改用圆润可爱字体(Fredoka + 站酷快乐体)
自托管字体子集:Fredoka(ASCII, 45KB) 与站酷快乐体
(GB2312 一级 3755 字, 451KB, unicode-range 限定,仅站名含汉字时下载)。
导航、首页页脚、后台侧边栏的站名统一使用。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:46:47 +08:00
chiyiandDevin 539c251f9f feat(logs): 日志页搜索合并为单个搜索框
后端新增 /logs?kw=:命中提示词/模型/错误,或命中用户
(名称/邮箱/ID) 的记录均保留;前端两个搜索框合并为一个。
首页导航恢复 ✦ 品牌小星标,与其他页一致。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:43:34 +08:00
chiyiandDevin cce126cd52 feat(admin): 表格页整体美化 — 筛选改下拉、统一工具栏、页头放刷新
- 全站卡片/表格/按钮/输入框/徽标共用样式换成墨黑中性风
- 用户/日志/模型/兑换码/用户日志的筛选胶囊改为下拉(SelectMenu 面板改亮色)
- 统一 toolbar:控件同高 36px,搜索框带图标且限宽
- 刷新按钮移到页头独立一行(统计卡上方),新增页面标题
- 修复浅色模式下 bg-white/ 规则误伤 hover:bg-white/ 导致行底色偏灰
- 用户表加最小宽度,修复邮箱列被挤成 0 宽

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:40:37 +08:00
chiyiandDevin 6372b7cb8b fix(ui): 切换页面时整体抖动 — 固定滚动条槽位,去掉切页位移动画
页面有/无滚动条时视口宽度变化会让居中内容和固定导航横向跳动;
html 与后台 main 加 scrollbar-gutter: stable。
同时去掉切页淡入里的 translateY,只保留透明度过渡。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:32:59 +08:00
chiyiandDevin 3a18406b23 feat(admin): 后台概览与外壳整体美化
- 概览页重写:合并成一条 KPI 带 + 资源条,趋势图放大,
  图表改墨黑单色,运营/分析卡片统一白底圆角
- 后台外壳:去掉重复的 ADMIN 顶栏,侧边栏选中态改轻量灰底+细指示条
- 数据逻辑与接口调用保持不变

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:31:29 +08:00
chiyiandDevin f65ce5c765 feat(layout): 去掉各页顶部布局级路由标题(图片/生图/文档/关于等)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:28:29 +08:00
chiyiandDevin 5e42db52b9 feat(nav): 余额与后台入口并入用户下拉,下拉重新设计
顶栏右侧只留头像+用户名胶囊;下拉含用户信息头、
余额卡(点击去充值)、带图标的菜单项、管理后台、退出登录。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:26:18 +08:00
chiyiandDevin f6e916c985 feat(canvas): 顶栏改悬浮岛 + 左侧工具坞,舞台铺满整屏
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:21:34 +08:00
chiyiandDevin 509b0d41c6 feat(canvas): 画布视觉精修
笔记改白色卡片,连接点仅悬停/选中显示,连线改灰色虚线,
节点边框与阴影更轻,输入框去灰渐变与光晕,顶栏按钮改描边款。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:20:22 +08:00
chiyiandDevin 4d31219368 feat(ui): 全站统一首页墨黑中性风格,固定亮色并移除光暗切换
- 全站紫/青/粉写死色值与 CSS 变量改为墨黑中性色板
- 移除 aurora 光斑/星点装饰,登录弹窗改纯白卡片
- theme.js 固定亮色并清除历史 dark 选择,删除前台/后台/画布切换按钮
- 模型页空状态文案对齐「保存渠道即自动同步」

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:17:55 +08:00
chiyiandDevin 8b85d5623a feat(showcase): 去掉 Bento 灵感类型,只保留 hero / work
后端校验、分组、后台列表,以及后台页面与首页取图都只认
hero 和 work;启动时清掉遗留的 bento 记录。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:57:12 +08:00
chiyiandDevin 2fa0324a66 feat(home): 去紫色,改中性暖白底 + 墨黑主色
整页底色 #fafaf8,按钮/CTA/导航激活态改墨黑,
标题渐变改黑灰,占位图换低饱和纸感色板,
去掉彩色光晕与漂移光斑。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:50:23 +08:00
chiyiandDevin 5ddce37ee6 feat(home): 整体美化 — 全页统一淡紫底、hero 柔光浮动、修能力矩阵留白
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:44:00 +08:00
chiyiandDevin c5cc2e9086 feat(home): 底色紫调再调淡一档
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:35:06 +08:00
chiyiandDevin a1f61d548d feat(home): 页面底色改紫色渐变铺满
整页背景由近白 #fafafb 换为薰衣草紫渐变
(#ede7fd → #d8defc),与卡片和渐变描边更统一。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:33:57 +08:00
chiyiandDevin 89a9d76b3e feat(home): 内容版心 1200 → 1400 铺满感
hero/能力/作品墙/CTA/页脚统一放宽到 1400px:
1440 视宽下内容贴边更饱满,1920 下仍居中留白均衡。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:31:49 +08:00
chiyiandDevin 8fd49db8c2 feat(home): 首页展示位空数据时渲染内置渐变图兜底
/showcase 返回空时 hero 拼贴与作品墙不再留白——
用 public/showcase 下 11 张内置 SVG 渐变图填充;
接口有数据时仍优先使用后台配置的展示图。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:08:07 +08:00
chiyiandDevin 466507b388 feat(home): 逐像素移植 vivid 旧版首页 + 悬浮胶囊导航
整页按 vivid commit 8b16666 的 HomeView 复刻:
- Hero 左文右图:模型在线徽章、「让想象/即刻成画」渐变标题、
  渐变描边魔法输入框、提示词胶囊、三项统计
- 右侧四格作品拼贴(带错位旋转/悬停浮层/shimmer 占位)
- 01·能力矩阵(bento 网格)、02·灵感作品墙(编号卡+引导卡)
- 紫色 CTA 横幅 + 分栏页脚(社交图标接 site.contact)
- 顶栏改 vivid 胶囊:999px 毛玻璃、滚动展开通栏、
  游客态单个渐变「登录/注册」按钮
- 页面局部覆盖 vivid 原版色板(--bg #fafafb、accent #6d4df2),
  隐藏 aurora 背景
- Icon 组件补 size prop 与缺失图标,修复 SVG 无尺寸撑爆布局

数据接本站 /managed-models(sellable) 与 /showcase(hero→拼贴,work→作品墙)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 14:58:17 +08:00
chiyiandDevin 4632b1ab04 feat(channel): 对齐 vivid 最新渠道/模型管理设计
渠道管理(对齐 9db9971/9f6eeca/b607a8d/268a8f7/7f8a87e):
- 列表新增「运行中」(Redis 并发槽位实时计数,与调度同源)和「失败数」
  (event_logs 按渠道聚合)两列
- 行内「同步上游模型」恢复弹窗:默认全开、右侧开关、彩点+单行别名,
  底部「同步模型」批量导入;编辑表单去掉获取模型列表按钮(保存已自动
  全量同步);去掉勾选即解绑语义与 unbind-model 端点
- 导入加固:别名与上游同名报错、同批别名复用拒绝、本地类型冲突拒绝、
  单次上限 50

模型管理(对齐 5545195):
- 模型表单/CreateModel 补齐 max_reference_videos/audios 与
  max_ref_video/audio_seconds 透传

产物(对齐 2253e58):
- 下载结果仅信任 image/*/video/* 响应头,text/plain 等乱报一律按魔数
  嗅探定 MIME

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 19:14:25 +08:00
chiyiandDevin 72d4eec203 feat(channel): 同步 vivid 最新渠道设计 — 手动全量同步端点/并发0=不限/同名别名报错
对齐 vivid 6039423/5802749/a1f808f/cfb6d49/aba8474/b01636f:
- 新增 POST /channels/sync-models:已保存渠道手动全量同步,上游不可达/
  空清单直接报错(保存路径仍降级为告警);渠道行按钮不再弹勾选框,
  表单内对已保存渠道同样直同步,新渠道保持预览+勾选即绑定
- 渠道并发 0 = 不限制(原 0=默认5),上限放开到 int32,槽位 Lua 本就
  max=0 放行
- 别名与上游模型同名提交即报错「留空即同名」,前后端双校验
- 渠道列表去掉上游地址与模型两列(同 vivid),状态徽章悬浮显示失败原因

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 01:43:30 +08:00
chiyiandDevin 5226e11cfc docs: 清理 OpenAI 时代残留,文档改述 vivid 协议 + 渠道架构
- README/README.en 重写:同构网关 + vivid 四端点 + 渠道同步/调度,
  明确不支持 OpenAI 协议上游;移除账号池/七大平台/OpenAI 兼容描述
- backend/frontend README、.env.example 更新(补 RUSTFS/COOKIE_SECURE/
  CHANNEL_SECRET)
- docker-compose 增加 CHANNEL_SECRET(渠道 key AES-GCM 密钥)
- 清掉代码注释/文档示例里的 OpenAI/gpt-image-2 残留

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 01:43:24 +08:00
82 changed files with 3574 additions and 3709 deletions
+99 -234
View File
@@ -1,305 +1,170 @@
<div align="center">
<img src="frontend/public/favicon.svg" width="84" alt="Vivid AI" />
<img src="frontend/public/favicon.svg" width="84" alt="image2api" />
<h1>image2api</h1>
**Multi-provider AI image / video generation gateway — one OpenAI-compatible API, seven platforms aggregated, a ready-to-run operations system**
**vivid-protocol AI image / video generation gateway — channel-based upstream routing, a ready-to-run operations system**
<sub>Live instance (brand): [Vivid AI · vividai.run](https://vividai.run)</sub>
[简体中文](README.md) | **English**
[![Online Demo](https://img.shields.io/badge/Live%20Demo-vividai.run-7c3aed?style=for-the-badge)](https://vividai.run)
[![Online Demo](https://img.shields.io/badge/demo-vividai.run-7c3aed?style=for-the-badge)](https://vividai.run)
[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](https://go.dev)
[![Vue 3](https://img.shields.io/badge/Vue-3-42b883?logo=vuedotjs&logoColor=white)](https://vuejs.org)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-deployment)
[![OpenAI Compatible](https://img.shields.io/badge/OpenAI-compatible-412991?logo=openai&logoColor=white)](#-openai-compatible-api)
[![HTTPS](https://img.shields.io/badge/HTTPS-your--proxy-lightgrey)](#-deployment)
[![Providers](https://img.shields.io/badge/providers-7-orange)](#-supported-models--providers)
[![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-deployment)
[![License](https://img.shields.io/badge/license-MIT-blue)](#-license)
[Live Demo](https://vividai.run) · [Features](#-features) · [Deploy](#-deployment) · [API](#-openai-compatible-api) · [Community](#-community--contact)
[Live Demo](https://vividai.run) · [Features](#-features) · [Deploy](#-deployment) · [API](#-vivid-protocol-api)
<br/>
<img src="docs/screenshots/playground.png" alt="image2api — playground" width="860" />
<img src="docs/screenshots/home.png" alt="image2api — landing" width="860" />
</div>
---
## 📖 Table of Contents
- [Overview](#-overview)
- [Screenshots](#-screenshots)
- [Features](#-features)
- [Supported Models / Providers](#-supported-models--providers)
- [OpenAI-Compatible API](#-openai-compatible-api)
- [Deployment](#-deployment)
- [Tech Stack](#-tech-stack)
- [Repository Layout](#-repository-layout)
- [Roadmap](#-roadmap)
- [Community / Contact](#-community--contact)
- [License](#-license)
## ✨ Overview
**image2api** wraps the image / video capabilities of Adobe Firefly, OpenAI, Runway, Grok, Leonardo, Krea and Imagine into **a single OpenAI-compatible API**. Behind it, multi-account pools are scheduled automatically — out of quota → switch account, auth expired → refresh or kill, transient errors → retry, tokens proactively renewed before they expire — to deliver a stable service.
It's more than an API proxy: it ships with **credit billing, CDK top-ups, referral rewards, a user system, an admin console, and a modern generation frontend**, so a single command turns it into a fully operational AI generation site — the author's live instance **[Vivid AI · vividai.run](https://vividai.run)** (brand) is built on this project.
> 💡 Both frontend and backend are **fully open-source** (MIT) — Go + Vue 3, free to fork and self-host.
**At a glance** 🔌 OpenAI-compatible · 🤖 7 platforms, 10+ models · 🔁 auto failover / token keep-alive · 💳 credits + agent pricing · 🎨 generation frontend + admin console · 🐳 one-command deploy (bring your own TLS proxy)
## 🖼️ Screenshots
<div align="center">
<sub>🎨 Modern generation frontend · light / dark admin console · data-driven ops dashboard</sub>
<sub>✨ Vivid-style light UI · channel routing · data-driven ops dashboard</sub>
</div>
<table>
<tr>
<td width="50%"><img src="docs/screenshots/dashboard.png" alt="Dashboard" /></td>
<td width="50%"><img src="docs/screenshots/models.png" alt="Models" /></td>
<td width="33%"><img src="docs/screenshots/playground.png" alt="Studio" /></td>
<td width="33%"><img src="docs/screenshots/dashboard.png" alt="Dashboard" /></td>
<td width="33%"><img src="docs/screenshots/models.png" alt="Models" /></td>
</tr>
<tr>
<td align="center"><b>📊 Dashboard</b><br/><sub>Users / volume / provider health / 24h trend</sub></td>
<td align="center"><b>🧩 Model management</b><br/><sub>Per-model capabilities, pricing & weight</sub></td>
<td align="center"><b>🎨 Studio</b><br/><sub>image + video · reference uploads · live pricing</sub></td>
<td align="center"><b>📊 Dashboard</b><br/><sub>users / volume / channel health / 24h trend</sub></td>
<td align="center"><b>🧩 Models</b><br/><sub>channel bindings + weights · standard + reseller pricing</sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/screenshots/accounts.png" alt="Accounts" /></td>
<td width="50%"><img src="docs/screenshots/logs.png" alt="Logs" /></td>
<td width="33%"><img src="docs/screenshots/channels.png" alt="Channels" /></td>
<td width="33%"><img src="docs/screenshots/users.png" alt="Users" /></td>
<td width="33%"><img src="docs/screenshots/logs.png" alt="Logs" /></td>
</tr>
<tr>
<td align="center"><b>🔑 Account pools</b><br/><sub>Multi-account pools · weight / concurrency · CRUD</sub></td>
<td align="center"><b>📜 Call logs</b><br/><sub>Success / failure / in-progress · prompts & latency</sub></td>
<td align="center"><b>🔌 Channels</b><br/><sub>save-to-sync upstream models · live balance · failover</sub></td>
<td align="center"><b>👥 Users</b><br/><sub>roles / concurrency groups / credits · batch ops</sub></td>
<td align="center"><b>📜 Logs</b><br/><sub>success / failed / running · prompts and latency kept</sub></td>
</tr>
</table>
## ✨ Introduction
**image2api** is a homogeneous gateway. Upstream, it connects any number of **vivid-protocol (vividai) instances** as "channels" and routes jobs across them. Downstream, it exposes the same **vivid-protocol API** to callers: `POST /v1/generate` (NDJSON stream) · `POST /v1/uploads` (presigned upload) · `GET /v1/models` · `GET /v1/balance`. No built-in provider code, no account pools — an upstream is just another vividai instance, so gateways can be layered arbitrarily.
It is more than a gateway: built-in **credit billing, CDK top-ups, online payment (epay), invite rewards, reseller pricing, user accounts, an admin console and a modern generation UI** — one command turns it into a production AI generation site. The author's live instance **[Vivid AI · vividai.run](https://vividai.run)** runs on this project.
> 💡 Frontend + backend fully open source (MIT). Go + Vue 3.
**One-liner** 🔁 vivid-protocol channel mesh · 📦 save-a-channel syncs all models · 💳 credits + payment + reseller pricing · 🧩 concurrency groups · 🎨 generation UI + admin console · 🐳 one-command deploy
## 🚀 Features
#### 🎨 Generation
- Images + videos in one place, with **image-to-image / reference frames** (first frame, last frame, style reference)
- Multiple resolutions (images 1K / 2K / 4K · videos 720p / 1080p), aspect ratios and video durations — configured and priced per model
- 7 providers, 10+ models, **enable / disable / re-price from the admin console**, no code changes
- **Model aliases**: one model can expose multiple public ids — API calls with any alias resolve to it
- **De-AI fingerprint** (optional): one-click toggle on the playground — generated images get anti-AI-detection post-processing (subtle detail jitter + metadata stripping), charged as a per-tier surcharge (defaults 1K+1 / 2K+2 / 4K+3 credits, admin-configurable, can be disabled globally); processed works carry a "de-AI" badge across the playground, gallery, logs and admin image manager
- Image + video in one place, with multi-modal references (images / first-last frames / video / audio) per model capability
- Multiple resolutions (1K/2K/4K images · 720p/1080p/768p video), aspect ratios, discrete or per-second video durations — all per-model configurable and priced
- **Priced = listed**: no enable switch — a model goes public once every tier is priced and an enabled channel serves it
- **Model aliases**: one upstream model can map to several public ids
- Optional **de-AI** post-processing on generated images (per-tier surcharge)
#### 🔌 OpenAI Compatible
- Text-to-image `/v1/images/generations` · image-to-image `/v1/images/edits` (multipart ref upload) · video `/v1/videos` (Sora-style async: create → poll → `/content`) · `/v1/models` · balance `/v1/user/balance` (remaining / cumulative used)
- **Strict OpenAI params**: `size` drives **both aspect ratio + resolution tier** (images by long edge → 1K/2K/4K, videos by short edge → 720p/1080p) — just swap `base_url` + `api_key` into an existing OpenAI SDK
- Image results returned **inline as base64** — nothing stored server-side, privacy-friendly; the in-app **/docs** ships a size ↔ tier reference table
#### 🔁 Channel routing
- **Save = full sync**: storing a channel pulls the upstream `/v1/models`, binds everything, unbinds what disappeared and deletes orphans
- **Weight + concurrency-aware** dispatch with failover; upstream 4xx rejections pass through in Chinese, transient errors move to the next channel
- Channel keys stored AES-GCM encrypted (`CHANNEL_SECRET`); upstream balance probed live
#### 🔁 Account Pools + Smart Failover
- Round-robin scheduling across the pool; one bad account doesn't break the whole
- **Out of quota → switch** · **auth expired → refresh & retry / kill** · **transient → retry same account ×3** · **bad params → fail fast**
- **Pre-deducted credits**: atomic debit before generation, auto-refunded on failure, no over-spend under concurrency
#### 🔌 vivid-protocol API
- `POST /v1/generate` — NDJSON stream: first frame returns `jobId`, ~25s blank-line heartbeats, terminal `succeeded`/`failed`
- `POST /v1/uploads` — presigned PUT direct-to-storage for reference files
- `GET /v1/models` — bare array with capability specs · `GET /v1/balance` — balance
- **jobId reconnect**: resubmit with only `jobId` to resume a dropped stream — no duplicate job or charge; pending jobs resume after a server restart too
- Unified errors `{"error":{"code","message"}}` (4001 rejected / 4002 insufficient / 4011 unauthorized / 4293 concurrency / 5001 server); API keys are `vk_` + 48 hex
#### 🔐 Automatic Token Keep-alive
- Single-use rotating tokens (Krea / Imagine) are **renewed proactively 10 minutes before expiry**; new tokens persisted automatically
- Adobe cookies exchanged for fresh tokens on a schedule; bare JWTs killed on expiry
- Daily quota recovered at each provider's reset time, then re-probed for the real balance
#### 💳 Billing & operations
- Credits (pre-charge + auto-refund), per model/tier/duration pricing; de-AI surcharge per tier
- **Reseller pricing**: reseller role + per-model reseller prices (API calls included)
- **Online payment (epay)**: WeChat/Alipay QR, fixed + custom amounts, unpaid orders auto-cancel after 30 min, MD5-verified idempotent callbacks
- **CDK codes** · invite rewards · email register / verification / password reset
- **Concurrency groups**: per-group in-flight caps across UI + API (`0` = unlimited)
- Roles: user / reseller / admin
#### 💳 Billing & Operations
- Credit-based (**pre-deduct + refund on failure**), priced per model / resolution / duration; de-AI fingerprint adds a per-tier surcharge
- **Agent pricing**: a user can be set as an "agent" role and models can carry agent prices; agent users (including their API key calls) are billed at the agent price, falling back to the normal price when unset
- **Online top-up (易支付 / epay)**: WeChat / Alipay QR, preset + custom amounts, unpaid orders auto-cancel after 30 min, MD5-verified idempotent callback auto-credits; cumulative top-up tracked
- **CDK redeem codes** · **referral rewards** · email sign-up / verification code / password reset
- **Concurrency groups**: cap a user's simultaneous generations (playground + API key combined, `0` = unlimited), self-healing Redis counters, new users auto-join the default group
- Three roles: regular user / agent / admin (single)
#### 🛠️ Admin console
- Dashboard (trends / DAU / top failures / top spenders)
- **Channels** (save-to-sync, upstream balance, enable, concurrency, weight) · models (standard + reseller pricing, aliases, read-only listable state) · concurrency groups · orders · global logs · users · CDK · media manager · showcase · announcements · site config
- **Banned words**: prompt hits rejected, per-word / per-user trigger stats
#### 🖥️ User Frontend (Vue 3)
- Playground · creations gallery · generation logs (with failure reasons / source tags)
- Gallery **multi-select batch ops**: select page / bulk delete (videos take their frame stills along) / bulk download (multiple files auto-packed into a zip, fetched concurrently)
- Lightbox preview with built-in **copy original / download / close** buttons; one-click copy of the original image from cards
- **Top-up · Orders** (recharge history / resume unpaid) · API docs · API key management · referral · about, light / dark theme
- **In-app announcements**: a Markdown notice pops up after login and re-shows whenever its content changes
**🧰 Engineering**: S3/RustFS media behind an authenticated proxy + retention cleanup · presigned reference uploads · self-healing maintenance (stale-job resume/refund, expired file pruning) · one-command Docker deploy (TLS is your reverse proxy's job).
#### 🛠️ Admin Console
- Overview dashboard (trends / DAU / top failures / top spenders)
- Model management (normal + agent price + aliases) · account management (bulk import / dedup / quota) · **concurrency groups** · **order management** (filter / search / paginate) · site-wide logs · user management (set as agent / assign concurrency group / view cumulative top-up / banned-word hits) · CDK · image management (multi-select bulk delete / zip download) · showcase · **announcements** · site config (incl. epay, de-AI fingerprint toggle & surcharge pricing)
- **Banned words**: add / remove words in the console (paginated + multi-select bulk delete); prompts containing a banned word are rejected outright (playground + API, case-insensitive), with per-word / per-user hit counters
## 🔌 Channel architecture
**🧰 Engineering highlights**: tls-client (Chrome JA3/JA4 fingerprint) reliably passes Cloudflare · media stored in S3/RustFS, served through an authenticated proxy with retention cleanup · self-healing maintenance loop (quota recovery / credential refresh / orphan-job cleanup with refunds) · one-command Docker deploy (TLS via your own reverse proxy).
## 🤖 Supported Models / Providers
| Provider | Models (examples) | Type |
|---|---|---|
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | Image / Video |
| **OpenAI** | gpt-image-2 | Image |
| **Runway** | runway-gen4-turbo · runway-nano-banana-2 (Nano Banana 2) · runway-nano-banana-pro (Nano Banana Pro) | Video / Image |
| **Grok (console.x.ai)** | grok-video (imagine text/image-to-video), grok-image / grok-image-quality (imagine text/image-to-image) | Video / Image |
| **Leonardo.ai** | seedream-4.5 | Image |
| **Krea.ai** | flux-klein-2 | Image |
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | Image |
> Models are enabled and priced dynamically from the admin console — add or remove anytime.
## 🔌 OpenAI-Compatible API
```bash
# Text-to-image — pure OpenAI params: size drives both aspect ratio + tier (long edge <1800→1K / <3500→2K / ≥3500→4K)
curl https://your-domain/v1/images/generations \
-H "Authorization: Bearer sk-xxxx" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-image-2",
"prompt": "a cute cat on a desk, studio lighting",
"size": "2048x2048"
}'
# Image-to-image — multipart reference upload (multiple via image[])
curl https://your-domain/v1/images/edits \
-H "Authorization: Bearer sk-xxxx" \
-F model="seedream-4.5" -F prompt="make it cyberpunk" -F image=@input.png
```
caller ──vivid protocol──▶ image2api ──vivid protocol──▶ upstream vividai A (weight w, conc c)
(this host) ├─ upstream vividai B
└─ …any number
```
Images return OpenAI-style `{ "created": ..., "data": [{ "b64_json": "..." }] }` (raw base64, no `data:` prefix, nothing stored server-side). **Video** is async: `POST /v1/videos` → poll `GET /v1/videos/{id}` until `completed` → `GET /v1/videos/{id}/content` for the mp4. Full parameters are documented on the in-app **/docs** page.
- A channel = a homogeneous upstream: `base_url` + `vk_` key + enable switch + concurrency cap + routing weight
- Models come from the upstream `/v1/models` sync; locally they only get aliases and pricing; **one model can fail over across channels**
- This service speaks the same protocol externally — it can itself be mounted as someone's channel, infinitely nestable
- **No OpenAI-protocol upstreams**: a channel must be a vivid-protocol instance
## 📡 vivid-protocol API
```bash
# generate — NDJSON stream; first frame returns jobId
curl -N https://your-domain/v1/generate \
-H "Authorization: Bearer vk_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"model": "seedance-2.0", "prompt": "a cute cat on a desk", "ratio": "16:9", "quality": "720p"}'
# → {"jobId":"…","status":"running"} …heartbeats… {"status":"succeeded","outputs":[{"url":"…"}]}
# reconnect — resubmit with only jobId, no duplicate job
curl -N https://your-domain/v1/generate -H "Authorization: Bearer vk_xxxx" -d '{"jobId":"<jobId>"}'
# reference files — presigned upload session, then PUT the bytes
curl https://your-domain/v1/uploads -H "Authorization: Bearer vk_xxxx" -d '{"kind":"image","mime":"image/png"}'
curl https://your-domain/v1/models -H "Authorization: Bearer vk_xxxx"
curl https://your-domain/v1/balance -H "Authorization: Bearer vk_xxxx"
```
Full parameters, the error-code table and Python examples live on the in-app **/docs** page.
## 🚀 Deployment
> Domain + HTTPS are handled by your own reverse proxy (this project issues no certificates).
**Docker (recommended)**: `docker compose up -d --build` brings up PostgreSQL + Redis + RustFS + backend + frontend (nginx listens on container **HTTP :2000**); point your reverse proxy at `http://<host>:2000`.
**Docker (recommended)**: `docker compose up -d --build` brings up PostgreSQL + Redis + RustFS + backend + frontend (nginx serving **HTTP on container port 2000**); point your reverse proxy at `http://<host>:2000` (port via `WEB_PORT`; edit the values (passwords / keys / `CORS_ORIGINS`, and `COOKIE_SECURE=true` when your proxy serves HTTPS) directly in `docker-compose.yml`).
> ⚠️ Change the secrets in `docker-compose.yml` before deploying — especially **`CHANNEL_SECRET`** (encrypts stored channel keys; rotating it makes saved keys undecryptable).
Or **build from source** — bring your own **PostgreSQL · Redis · RustFS (or any S3) · reverse proxy**:
Manual build needs PostgreSQL · Redis · RustFS (or any S3) · a reverse proxy — see `backend/.env.example` for all env vars.
```bash
# 1. Create an empty database (the backend auto-migrates on start)
createdb vivid_ai
After deploy: admin → Channels → add channel (upstream URL + `vk_` key) → models auto-sync → price them in 模型管理 → done.
# 2. Configure and build the backend from source
cat > backend/.env <<'EOF'
APP_ENV=production
HTTP_ADDR=127.0.0.1:6666
POSTGRES_DSN=host=127.0.0.1 user=postgres password=YOUR_PASSWORD dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai
REDIS_ADDR=127.0.0.1:6379
RUSTFS_ENDPOINT=http://127.0.0.1:9000
RUSTFS_BUCKET=vivid-ai
RUSTFS_ACCESS_KEY=YOUR_AK
RUSTFS_SECRET_KEY=YOUR_SK
CORS_ORIGINS=https://your-domain
COOKIE_SECURE=true
EOF
cd backend && go build -o bin/api ./cmd/api && ./bin/api # listens on 127.0.0.1:6666
## 🧱 Stack
# 3. Build the frontend (output in frontend/dist)
cd frontend && npm install && npm run build
```
Nginx reverse proxy (issue the certificate yourself with certbot / acme.sh):
```nginx
server {
listen 443 ssl;
server_name your-domain;
ssl_certificate /path/fullchain.pem;
ssl_certificate_key /path/privkey.pem;
root /path/to/frontend/dist;
index index.html;
client_max_body_size 50m;
proxy_read_timeout 600s; # video generation can take a while
location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; }
location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }
location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; }
location ^~ /images/ { proxy_pass http://127.0.0.1:6666; }
location = /health { proxy_pass http://127.0.0.1:6666; }
location ^~ /v1/ { proxy_pass http://127.0.0.1:6666; add_header Cache-Control "no-store" always; }
}
```
> See `backend/.env.example` for the full set of environment variables.
</details>
## 🧱 Tech Stack
| Layer | Technology |
| Layer | Tech |
|---|---|
| Backend | Go · gin · gorm (PostgreSQL) · go-redis · tls-client (Chrome fingerprint) |
| Backend | Go · gin · gorm (PostgreSQL) · go-redis |
| Frontend | Vue 3 · Vue Router · Vite · Tailwind CSS v4 |
| Infrastructure | PostgreSQL · Redis · RustFS (S3-compatible) · Nginx |
| Infra | PostgreSQL · Redis · RustFS (S3-compatible) · Nginx |
## 📦 Repository Layout
```
backend/ Backend source (Go)
├── cmd/
│ ├── api/ Service entry point (main)
│ └── marklabel/ Ops helper (mark accounts on demand)
├── internal/
│ ├── bootstrap/ App wiring, scheduled maintenance startup
│ ├── config/ Env-var configuration loading
│ ├── http/
│ │ ├── handler/ HTTP handlers (v1-compatible API, admin, auth…)
│ │ ├── middleware/ Auth / request-id middleware
│ │ └── router/ Route registration
│ ├── model/ GORM data models
│ ├── provider/ Upstream provider clients
│ │ ├── adobe/ Adobe Firefly (tls-client fingerprint)
│ │ ├── chatgpt/ OpenAI (incl. PoW / turnstile)
│ │ ├── runway/ Runway video + Nano Banana image
│ │ ├── grok/ Grok (console.x.ai DPoP, video / image; account state via grok.com)
│ │ ├── leonardo/ Leonardo
│ │ ├── krea/ Krea
│ │ ├── imagine/ Imagine.art
│ │ ├── custom/ Custom upstream (OpenAI-compatible v1, routed by id)
│ │ └── epay/ 易支付 / epay (mapi order + MD5-verified callback, top-ups)
│ ├── repo/ Data-access layer (users / models / accounts / logs / CDK / orders / concurrency groups…)
│ ├── service/ Business logic (scheduling, billing, account pools, keep-alive, maintenance)
│ └── storage/ RustFS / S3 media storage
├── Dockerfile Multi-stage build (compile source → slim runtime image)
└── .env.example Backend env-var template
frontend/ Frontend source (Vue 3 + Vite)
├── src/
│ ├── views/ Pages (playground / accounts / models / users / concurrency / orders / logs / overview / top-up / settings…)
│ ├── components/ Reusable components (modals / selectors / lightbox…)
│ ├── layouts/ Public / admin layouts
│ ├── utils/ Utility functions
│ └── api.js · auth.js … API client, auth, theme, credits, etc.
├── Dockerfile Nginx static hosting (HTTP :2000) + API proxy
└── default.conf.template Nginx site template (reverse proxy + caching)
docker-compose.yml Docker orchestration (Postgres / Redis / RustFS / backend / frontend)
```
## 🗺️ Roadmap
- [ ] More upstream providers
- [ ] Usage analytics / export
- [ ] Multi-language UI (i18n)
- [ ] Webhook / async callbacks
## 💬 Community / Contact
## 💬 Contact
| | |
|---|---|
| 🌐 Website | **[vividai.run](https://vividai.run)** |
| 👥 QQ Group | **1106849765** · [Join](https://qm.qq.com/q/976LeMFoHu) |
| 🐧 QQ | **1114639355** · [Add](https://qm.qq.com/q/ItgCcNA7ac) |
| 🛒 Shop | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** |
| 🌐 Site | **[vividai.run](https://vividai.run)** |
| 👥 QQ group | **1106849765** · [join](https://qm.qq.com/q/976LeMFoHu) |
| 🐧 QQ | **1114639355** · [add](https://qm.qq.com/q/ItgCcNA7ac) |
| ✉️ Email | vividairun@gmail.com |
## ⭐ Star History
<!-- After creating the GitHub repo, uncomment the line below and replace OWNER with your username to show the chart: -->
<!-- [![Star History Chart](https://api.star-history.com/svg?repos=OWNER/image2api&type=Date)](https://star-history.com/#OWNER/image2api&Date) -->
If you find this useful, give it a ⭐ — uncomment the line above after creating the repo to show the Star History chart.
## 📄 License
This project (frontend + backend) is open-source under the [MIT](LICENSE) license — free to use, modify, commercialize and redistribute.
<div align="center">
If this project helps you, a ⭐ Star is much appreciated!
</div>
MIT — see [LICENSE](LICENSE).
+94 -141
View File
@@ -1,10 +1,10 @@
<div align="center">
<img src="frontend/public/favicon.svg" width="84" alt="Vivid AI" />
<img src="frontend/public/favicon.svg" width="84" alt="image2api" />
<h1>image2api</h1>
**多供应商 AI 生图 / 生视频网关 —— 一套 OpenAI 兼容 API,聚合七大平台,开箱即用的运营系统**
**vivid 协议 AI 生图 / 生视频网关 —— 渠道化接入同构上游,开箱即用的运营系统**
<sub>线上实例(品牌):[Vivid AI · vividai.run](https://vividai.run)</sub>
@@ -15,17 +15,14 @@
[![Go](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go&logoColor=white)](https://go.dev)
[![Vue 3](https://img.shields.io/badge/Vue-3-42b883?logo=vuedotjs&logoColor=white)](https://vuejs.org)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](#-部署)
[![OpenAI Compatible](https://img.shields.io/badge/OpenAI-compatible-412991?logo=openai&logoColor=white)](#-openai-兼容-api)
[![HTTPS](https://img.shields.io/badge/HTTPS-反代自理-lightgrey)](#-部署)
[![Providers](https://img.shields.io/badge/供应商-7%20平台-orange)](#-支持的模型--供应商)
[![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success)](#-部署)
[![License](https://img.shields.io/badge/license-MIT-blue)](#-license)
[在线体验](https://vividai.run) · [功能](#-核心功能) · [部署](#-部署) · [API 文档](#-openai-兼容-api) · [交流群](#-交流--联系)
[在线体验](https://vividai.run) · [功能](#-核心功能) · [部署](#-部署) · [API 文档](#-vivid-协议-api) · [交流群](#-交流--联系)
<br/>
<img src="docs/screenshots/playground.png" alt="image2api — 画图台" width="860" />
<img src="docs/screenshots/home.png" alt="image2api — 首页" width="860" />
</div>
@@ -36,46 +33,49 @@
- [简介](#-简介)
- [界面预览](#-界面预览)
- [核心功能](#-核心功能)
- [支持的模型 / 供应商](#-支持的模型--供应商)
- [OpenAI 兼容 API](#-openai-兼容-api)
- [渠道架构](#-渠道架构)
- [vivid 协议 API](#-vivid-协议-api)
- [部署](#-部署)
- [技术栈](#-技术栈)
- [仓库结构](#-仓库结构)
- [Roadmap](#-roadmap)
- [交流 / 联系](#-交流--联系)
- [License](#-license)
## ✨ 简介
**image2api** 把 Adobe Firefly、OpenAI、Runway、Grok、Leonardo、Krea、Imagine 等平台,以及**任意 OpenAI 兼容上游**的图像 / 视频能力,统一封装成**一套 OpenAI 兼容的 API**;背后用多账号池自动调度 —— 权重优先 + 并发感知、额度耗尽自动换号、认证失效自动刷新或判死、临时错误自动重试、token 到期前主动续期 —— 对外提供稳定服务。
**image2api** 是一个同构网关:对上,把任意多个 **vivid 协议(vividai)实例**接成「渠道」统一调度;对下,对外暴露同一套 **vivid 协议 API**(`POST /v1/generate` NDJSON 流 · `POST /v1/uploads` 预签名直传 · `GET /v1/models` · `GET /v1/balance`)。没有内置供应商代码、没有账号池 —— 上游就是另一个 vividai,套娃式组网任意层叠。
它不只是 API 代理:自带**积分计费、CDK 充值、邀请奖励、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)**(品牌)即基于本项目搭建。
它不只是 API 网关:自带**积分计费、CDK 充值、在线充值(易支付)、邀请奖励、代理价体系、用户体系、管理后台、现代化画图前端**,一条命令即可跑成一个对外运营的 AI 生成站点 —— 作者的线上实例 **[Vivid AI · vividai.run](https://vividai.run)** 即基于本项目搭建。
> 💡 前后端**完全开源**(MIT),Go + Vue 3,可自由二开 / 自部署。
**一句话亮点** 🔌 OpenAI 兼容 · 🤖 7 平台十余模型 · 🔁 自动换号 / Token 保活 · 💳 积分 + 在线充值(易支付)+ 代理价 · 🧩 并发分组 · 🎨 画图前端 + 管理后台 · 🐳 一键部署(TLS 反代自理)
**一句话亮点** 🔁 vivid 协议渠道组网 · 📦 保存渠道即全量同步模型 · 💳 积分 + 充值 + 代理价 · 🧩 并发分组 · 🎨 画图前端 + 管理后台 · 🐳 一键部署(TLS 反代自理)
## 🖼️ 界面预览
<div align="center">
<sub>🎨 现代化画图前端 · 深 / 浅双主题管理后台 · 数据驱动的运营看板</sub>
<sub>✨ Vivid 风格浅色界面 · 渠道化调度 · 数据驱动的运营看板</sub>
</div>
<table>
<tr>
<td width="50%"><img src="docs/screenshots/dashboard.png" alt="概览看板" /></td>
<td width="50%"><img src="docs/screenshots/models.png" alt="模型管理" /></td>
<td width="33%"><img src="docs/screenshots/playground.png" alt="画图台" /></td>
<td width="33%"><img src="docs/screenshots/dashboard.png" alt="概览看板" /></td>
<td width="33%"><img src="docs/screenshots/models.png" alt="模型管理" /></td>
</tr>
<tr>
<td align="center"><b>📊 概览看板</b><br/><sub>用户 / 生成量 / Provider 健康 / 24h 趋势</sub></td>
<td align="center"><b>🧩 模型管理</b><br/><sub>按模型独立配置能力、定价与权重</sub></td>
<td align="center"><b>🎨 画图台</b><br/><sub>生图 / 生视频一站式 · 参考图上传 · 实时计价</sub></td>
<td align="center"><b>📊 概览看板</b><br/><sub>用户 / 生成量 / 渠道健康 / 24h 趋势</sub></td>
<td align="center"><b>🧩 模型管理</b><br/><sub>渠道绑定与权重 · 普通价 + 代理价 · 配价即上架</sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/screenshots/accounts.png" alt="账号池管理" /></td>
<td width="50%"><img src="docs/screenshots/logs.png" alt="调用日志" /></td>
<td width="33%"><img src="docs/screenshots/channels.png" alt="渠道管理" /></td>
<td width="33%"><img src="docs/screenshots/users.png" alt="用户管理" /></td>
<td width="33%"><img src="docs/screenshots/logs.png" alt="调用日志" /></td>
</tr>
<tr>
<td align="center"><b>🔑 账号池管理</b><br/><sub>多账号池 · 权重 / 并发 · 一键增删改</sub></td>
<td align="center"><b>🔌 渠道管理</b><br/><sub>保存即全量同步上游模型 · 余额实时探测 · 故障转移</sub></td>
<td align="center"><b>👥 用户管理</b><br/><sub>角色 / 并发组 / 积分 · 批量选中操作</sub></td>
<td align="center"><b>📜 调用日志</b><br/><sub>成功 / 失败 / 进行中 · 提示词与耗时全留痕</sub></td>
</tr>
</table>
@@ -83,117 +83,98 @@
## 🚀 核心功能
#### 🎨 生成能力
- 生图 + 生视频一站式,支持**图生图 / 参考图**(首帧、末帧、风格参考)
- 多分辨率(图像 1K / 2K / 4K · 视频 720p / 1080p)、多宽高比、视频多时长,按模型独立配置与定价
- 7 大供应商、十余模型,后台**动态启用 / 下架 / 改价**,无需改代码
- **模型别名**:同一模型可配多个对外 id,API 调用任意别名均可命中
- **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理(细节微扰 + 去除元数据),按画质档位加收积分(默认 1K+1 / 2K+2 / 4K+3,后台可改价、可整体关闭);带标记的作品在画图台、创作记录、日志与后台图片管理中均有「去AI特征」标识
- 生图 + 生视频一站式,支持**参考图 / 首末帧 / 视频 / 音频**等多模态参考(按模型能力声明下发)
- 多分辨率(图像 1K / 2K / 4K · 视频 720p / 1080p / 768p)、多宽高比、视频多时长(离散档或区间档按秒计价),按模型独立配置与定价
- **配价即上架**:模型无启停开关,全部档位有价 + 绑定了启用渠道即对外可见;不定价 = 隐藏
- **模型别名**:同一上游模型可配多个对外 id,API 调用任意别名均可命中
- **去AI特征**(可选):画图台一键开启,生成图片自动做去AI痕迹处理,按画质档位加收积分(后台可改价、可整体关闭)
#### 🔌 OpenAI 兼容
- 文生图 `/v1/images/generations` · 图生图 `/v1/images/edits`(multipart 上传参考图) · 视频 `/v1/videos`(Sora 式异步:创建→轮询→`/content` 下载) · `/v1/models` · 余额 `/v1/user/balance`(剩余/累计已用)
- **严格 OpenAI 入参**:`size` **同时决定比例 + 分辨率档**(图像看长边 → 1K/2K/4K,视频看短边 → 720p/1080p),改个 `base_url` + `api_key` 即接现有 OpenAI SDK
- 图片结果 **base64 直返**,服务端不留存文件,隐私友好;站内 **/docs** 附「分辨率对照表」直接查 `size` 该传什么
#### 🔁 渠道调度
- **保存渠道即全量同步**:填上游 `base_url` + `vk_` key 保存,自动拉取上游 `/v1/models` 全部入库绑定;上游消失的模型自动解绑,无其他渠道的孤儿模型自动清理
- **权重 + 并发感知调度**:渠道可配权重(加权随机)与并发上限,逐渠道尝试、失败自动故障转移
- **错误分类**:上游 4xx(参数/额度)直接透传中文拒绝理由、不重试;网络/5xx 换渠道重试
- 渠道 API Key 以 AES-GCM 加密落库(`CHANNEL_SECRET`),上游余额实时探测
#### 🔁 多账号池 + 智能故障转移
- 账号池调度,单账号出错不影响整体
- **权重优先 + 并发感知**:按账号权重从高到低调度,某账号并发满了才轮到下一个;同权重组内 round-robin 均摊。每账号并发数可配(上游账号),其余系统固定
- **额度耗尽→换号** · **认证失效→刷新重试 / 判死** · **临时错误→同号重试 ×3** · **参数错→直接报错**
- **预扣额度**:生成前原子扣减,失败自动退回,杜绝并发超额
#### 🔗 自定义上游聚合(OpenAI 兼容)
- 把任意 **OpenAI 兼容的 v1 端点**当成一个账号接入(填 `base_url` + `key`),无需写代码
- **按 model id 自动路由**:上游声明支持哪些 id,生成该 id 时即走对应上游(可覆盖内置 provider);id 留空 = 全部
- 模型管理里自由新建自定义模型(id / 类型 / 比例 / 分辨率·价 / 时长·价 / 参考图),按本地价计费
- 调用**直连不走代理**;上游可配权重与并发,与内置池统一调度
#### 🔐 Token 自动保活
- 一次性轮换 token(Krea / Imagine)**到期前 10 分钟主动续期**,新 token 自动落库
- Adobe cookie 定时换 token;纯 JWT 到期自动判死
- 每日额度按平台重置时间自动恢复 + 重新探测真实余额
#### 🔌 vivid 协议 API
- `POST /v1/generate` —— NDJSON 流式生成:首行回执 `jobId`,约 25s 空行心跳,终帧 `succeeded`/`failed`
- `POST /v1/uploads` —— 预签名 PUT 直传参考文件到对象存储
- `GET /v1/models` —— 裸数组,含能力规格(清晰度档位 / 比例 / 时长 / 参考文件上限)
- `GET /v1/balance` —— 余额查询
- **jobId 断流重连**:断流后仅带 `jobId` 重发同一请求即可续等,不重复下单、不重复扣费;进程重启后带 `jobId` 的任务也会自动续等
- 统一错误包 `{"error":{"code","message"}}`(4001 参数拒绝 / 4002 积分不足 / 4011 未授权 / 4293 并发上限 / 5001 服务异常);API Key 为 `vk_` + 48 hex
#### 💳 计费与运营
- 积分制(**预扣 + 失败退款**),按模型 / 分辨率 / 时长精细定价;去AI特征按画质另计附加积分
- **代理价体系**:用户可设为「代理」角色,模型可设代理价;代理用户(含其 API Key 调用)自动按代理价计费,未设代理价则回退普通价
- **在线充值(易支付)**:微信 / 支付宝扫码,金额档位 + 自定义,订单 30 分钟未付自动取消,支付回调 MD5 验签 + 幂等自动到账;累计充值可查
- **在线充值(易支付)**:微信 / 支付宝扫码,金额档位 + 自定义,订单 30 分钟未付自动取消,支付回调 MD5 验签 + 幂等自动到账
- **CDK 兑换码**充值 · **邀请奖励** · 邮箱注册 / 验证码 / 找回密码
- **并发分组**:按分组限制用户「同时生成数」(画图台 + API Key 合计,`0` = 不限),Redis 自愈计数,新用户自动入默认组
- 三级角色:普通用户 / 代理 / 管理员(唯一)
- 三级角色:普通用户 / 代理 / 管理员
#### 🖥️ 用户前台(Vue 3)
- 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签)
- 作品画廊**多选批量操作**:全选本页 / 批量删除(视频连带首尾帧图) / 批量下载(多个自动打包 zip,并发拉取)
- 预览大图内置**复制原图 / 下载 / 关闭**按钮,卡片一键复制原图
- **充值 · 订单**(充值记录 / 未付可继续支付) · API 文档 · API Key 管理 · 邀请 · 关于,亮 / 暗主题
- **站内公告**:登录后自动弹出 Markdown 公告,内容更新即重推
- 作品画廊**多选批量操作**:全选本页 / 批量删除 / 批量下载(自动打包 zip)
- **充值 · 订单** · API 文档 · API Key 管理 · 邀请 · 无限画布
- **站内公告**:登录后自动弹出 Markdown 公告
#### 🛠️ 管理后台
- 概览看板(趋势 / DAU / 失败 Top / 消费榜)
- 模型管理(普通价 + 代理价 + 别名) · 账号管理(批量导入 / 去重 / 额度) · **并发分组** · **订单管理**(筛选 / 搜索 / 分页) · 全站日志 · 用户管理(设为代理 / 分配并发组 / 看累计充值 / 违禁触发次数) · CDK · 图片管理(多选批量删除 / zip 打包下载) · 展示位 · **站点公告** · 站点配置(含易支付、去AI特征开关与附加价格)
- **违禁词管理**:后台增删违禁词(分页 + 多选批量删除),提示词命中即拦截(画图台 + API,不区分大小写),按词 / 按用户统计触发次数
- **渠道管理**(保存即同步模型 / 上游余额 / 启用开关 / 并发 / 权重)· 模型管理(普通价 + 代理价 + 别名,上架状态只读)· **并发分组** · **订单管理** · 全站日志 · 用户管理(设为代理 / 分配并发组)· CDK · 图片管理 · 展示位 · **站点公告** · 站点配置(易支付、去AI特征等)
- **违禁词管理**:提示词命中即拦截,按词 / 按用户统计触发次数
**🧰 工程亮点**:tls-client(Chrome JA3/JA4 指纹)稳定穿透 Cloudflare · 媒体存 S3/RustFS 经鉴权代理分发 + 保留期清理 · 自愈式维护轮询(恢复额度 / 刷新凭据 / 清理僵死任务并退款) · 一条命令 Docker 部署(TLS 交给你的反代)。
**🧰 工程亮点**:媒体存 S3/RustFS 经鉴权代理分发 + 保留期清理 · 预签名直传参考文件 · 自愈式维护轮询(僵死任务续等 / 退款、过期文件清理)· 一条命令 Docker 部署(TLS 交给你的反代)。
## 🤖 支持的模型 / 供应商
## 🔌 渠道架构
| 供应商 | 模型(示例) | 类型 |
|---|---|---|
| **Adobe Firefly** | firefly-image-5 · firefly-gpt-image-2 · flux-kontext-max · nano-banana-2 · nano-banana-pro · firefly-video · firefly-ray · gemini-veo31 | 图像 / 视频 |
| **OpenAI** | gpt-image-2 | 图像 |
| **Runway** | runway-gen4-turbo · runway-nano-banana-2 (Nano Banana 2) · runway-nano-banana-pro (Nano Banana Pro) | 视频 / 图像 |
| **Grok(console.x.ai)** | grok-video(imagine 文生 / 图生视频)、grok-image / grok-image-quality(imagine 文生图 / 图生图) | 视频 / 图像 |
| **Leonardo.ai** | seedream-4.5 | 图像 |
| **Krea.ai** | flux-klein-2 | 图像 |
| **Imagine.art** | imagine-1.5 · imagine-1.5pro | 图像 |
| **自定义上游** | 任意 OpenAI 兼容 v1 端点(按 id 路由) | 图像 / 视频 |
> 模型由管理后台动态启用并定价,可随时增删。自定义上游支持把任何 OpenAI 兼容服务接成账号,按 model id 路由调用。
## 🔌 OpenAI 兼容 API
```bash
# 文生图 —— 纯 OpenAI 参数:size 同时决定比例 + 分辨率档(长边 <1800→1K / <3500→2K / ≥3500→4K)
curl https://你的域名/v1/images/generations \
-H "Authorization: Bearer sk-xxxx" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-image-2",
"prompt": "a cute cat on a desk, studio lighting",
"size": "2048x2048"
}'
# 图生图 —— multipart 上传参考图(可多张 image[])
curl https://你的域名/v1/images/edits \
-H "Authorization: Bearer sk-xxxx" \
-F model="seedream-4.5" -F prompt="改成赛博朋克风格" -F image=@input.png
```
调用方 ──vivid 协议──▶ image2api ──vivid 协议──▶ 上游 vividai 实例 A (渠道, 权重 w, 并发 c)
(本服务) ├─ 上游 vividai 实例 B
└─ …任意多个
```
图片返回 OpenAI 风格 `{ "created": ..., "data": [{ "b64_json": "..." }] }`(原始 base64,无 `data:` 前缀,服务端不留存)。**视频**走异步:`POST /v1/videos` 建任务 → 轮询 `GET /v1/videos/{id}` 至 `completed` → `GET /v1/videos/{id}/content` 取 mp4。完整参数见站内 **/docs** 文档页。
- 渠道 = 一个同构上游:`base_url` + `vk_` API Key + 启用开关 + 并发上限 + 调度权重
- 模型来自渠道同步(上游 `/v1/models`),本地只做别名映射与定价;**模型可在多个渠道间故障转移**
- 本服务对外同样说 vivid 协议 —— 所以它自己也能被接成别人的渠道,无限层叠组网
- **不支持接 OpenAI 协议上游**:上游必须是 vivid 协议实例
## 📡 vivid 协议 API
```bash
# 文生图 / 生视频 —— NDJSON 流,首行回执 jobId
curl -N https://你的域名/v1/generate \
-H "Authorization: Bearer vk_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"model": "seedance-2.0", "prompt": "a cute cat on a desk", "ratio": "16:9", "quality": "720p"}'
# → {"jobId":"…","status":"running"} …空行心跳… {"status":"succeeded","outputs":[{"url":"…"}]}
# 断流重连:只带 jobId 重发,不重复下单
curl -N https://你的域名/v1/generate -H "Authorization: Bearer vk_xxxx" -d '{"jobId":"<上文 jobId>"}'
# 参考文件:先拿预签名上传会话,PUT 直传后拿 ref key
curl https://你的域名/v1/uploads -H "Authorization: Bearer vk_xxxx" -d '{"kind":"image","mime":"image/png"}'
# 模型清单(裸数组)与余额
curl https://你的域名/v1/models -H "Authorization: Bearer vk_xxxx"
curl https://你的域名/v1/balance -H "Authorization: Bearer vk_xxxx"
```
完整参数、错误码表与 Python 示例见站内 **/docs** 文档页。
## 🚀 部署
> 域名 + HTTPS 由你自己的反向代理处理(本项目不内置证书签发)。
**Docker(推荐)**:`docker compose up -d --build` 一条命令拉起 PostgreSQL + Redis + RustFS + 后端 + 前端(nginx **HTTP 监听容器 2000 端口**),把你的反向代理指到 `http://<本机>:2000`(端口用 `WEB_PORT` 改;要改密码 / 密钥 / `CORS_ORIGINS`(反代走 HTTPS 时把 `COOKIE_SECURE` 设为 `true`),直接改 `docker-compose.yml` 里对应值即可)。
**Docker(推荐)**:`docker compose up -d --build` 一条命令拉起 PostgreSQL + Redis + RustFS + 后端 + 前端(nginx **HTTP 监听容器 2000 端口**),把你的反向代理指到 `http://<本机>:2000`。
也可**从源码手动构建**,自备 **PostgreSQL · Redis · RustFS(或任意 S3)· 反向代理**:
> ⚠️ 部署前把 `docker-compose.yml` 里的密码 / 密钥改掉 —— 特别是 **`CHANNEL_SECRET`**(渠道上游 Key 的加密密钥,变更后已存渠道密钥无法解密)。
也可**从源码手动构建**,自备 PostgreSQL · Redis · RustFS(或任意 S3)· 反向代理:
```bash
# 1. 创建空库(后端启动自动建表)
createdb vivid_ai
# 2. 配置并从源码构建后端
cat > backend/.env <<'EOF'
APP_ENV=production
HTTP_ADDR=127.0.0.1:6666
POSTGRES_DSN=host=127.0.0.1 user=postgres password=你的密码 dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai
REDIS_ADDR=127.0.0.1:6379
RUSTFS_ENDPOINT=http://127.0.0.1:9000
RUSTFS_BUCKET=vivid-ai
RUSTFS_ACCESS_KEY=你的AK
RUSTFS_SECRET_KEY=你的SK
CORS_ORIGINS=https://你的域名
COOKIE_SECURE=true
EOF
# 2. 配置并从源码构建后端 —— 完整环境变量见 backend/.env.example
cd backend && go build -o bin/api ./cmd/api && ./bin/api # 监听 127.0.0.1:6666
# 3. 构建前端(产物 frontend/dist)
@@ -212,7 +193,6 @@ server {
index index.html;
client_max_body_size 50m;
proxy_read_timeout 600s; # 视频生成耗时长
location /assets/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; }
location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }
location ^~ /admin/api/ { proxy_pass http://127.0.0.1:6666; }
@@ -222,13 +202,13 @@ server {
}
```
> 完整环境变量见 `backend/.env.example`。
部署后:后台「渠道」页新建渠道(上游 URL + `vk_` key)→ 保存即自动同步全部上游模型 → 模型管理里配价 → 画图台 / 对外 API 立即可用。
## 🧱 技术栈
| 层 | 技术 |
|---|---|
| 后端 | Go · gin · gorm(PostgreSQL)· go-redis · tls-client(Chrome 指纹) |
| 后端 | Go · gin · gorm(PostgreSQL)· go-redis |
| 前端 | Vue 3 · Vue Router · Vite · Tailwind CSS v4 |
| 基础设施 | PostgreSQL · Redis · RustFS(S3 兼容)· Nginx |
@@ -236,54 +216,34 @@ server {
```
backend/ 后端源码(Go)
├── cmd/
│ ├── api/ 服务入口(main)
│ └── marklabel/ 运维小工具(按需标记账号)
├── cmd/api/ 服务入口(main)
├── internal/
│ ├── bootstrap/ 应用装配、定时维护任务启动
│ ├── config/ 环境变量配置加载
│ ├── http/
│ │ ├── handler/ HTTP 处理器(v1 兼容接口、后台、鉴权…)
│ │ ├── handler/ HTTP 处理器(/v1 vivid 协议、渠道管理、鉴权…)
│ │ ├── middleware/ 鉴权 / 请求 ID 等中间件
│ │ └── router/ 路由注册
│ ├── model/ GORM 数据模型
│ ├── provider/ 各上游供应商客户端
│ │ ├── adobe/ Adobe Firefly(tls-client 指纹)
│ │ ├── chatgpt/ OpenAI(含 PoW / turnstile)
│ │ ├── runway/ Runway 视频 + Nano Banana 图像
│ │ ├── grok/ Grok(console.x.ai DPoP,视频 / 图像;账号态走 grok.com)
│ │ ├── leonardo/ Leonardo
│ │ ├── krea/ Krea
│ │ ├── imagine/ Imagine.art
│ │ ├── custom/ 自定义上游(OpenAI 兼容 v1,按 id 路由,直连不走代理)
│ │ └── epay/ 易支付(mapi 下单 + 回调 MD5 验签,积分充值)
│ ├── repo/ 数据访问层(用户 / 模型 / 账号 / 日志 / CDK / 订单 / 并发组…)
│ ├── service/ 业务逻辑(生成调度、计费、账号池、保活、维护)
│ └── storage/ RustFS / S3 媒体存储
├── Dockerfile 多阶段构建(源码编译 → 精简运行镜像)
│ ├── model/ GORM 数据模型(渠道 / 模型 / 用户 / 事件…)
│ ├── upstream/ vivid 协议上游客户端(NDJSON / 上传 / 模型 / 余额)
│ ├── repo/ 数据访问层
│ ├── service/ 业务逻辑(渠道调度、计费、维护、v1 job 生命周期)
│ └── storage/ RustFS / S3 媒体存储(预签名 PUT/GET)
├── Dockerfile 多阶段构建
└── .env.example 后端环境变量模板
frontend/ 前端源码(Vue 3 + Vite)
├── src/
│ ├── views/ 页面(画图台 / 账号 / 模型 / 用户 / 并发组 / 订单 / 日志 / 概览 / 充值 / 设置…)
│ ├── views/ 页面(画图台 / 渠道 / 模型 / 用户 / 并发组 / 订单 / 日志 / 概览 / 充值 / 设置…)
│ ├── components/ 复用组件(弹窗 / 选择器 / 灯箱…)
│ ├── layouts/ 公共 / 后台布局
│ ├── utils/ 工具函数
│ └── api.js · auth.js … 接口封装、鉴权、主题、积分等
├── Dockerfile Nginx 静态托管(HTTP :2000)+ API 反代
└── default.conf.template Nginx 站点模板(反代 + 缓存策略)
docker-compose.yml Docker 编排(Postgres / Redis / RustFS / 后端 / 前端)
.env.example → backend/.env 后端环境变量模板
```
## 🗺️ Roadmap
- [ ] 更多上游供应商接入
- [ ] 用量统计 / 导出
- [ ] 多语言界面(i18n)
- [ ] Webhook / 异步回调
## 💬 交流 / 联系
| | |
@@ -294,13 +254,6 @@ docker-compose.yml Docker 编排(Postgres / Redis / RustFS / 后端
| 🛒 小店 | **[pay.ldxp.cn/shop/chiyi](https://pay.ldxp.cn/shop/chiyi)** |
| ✉️ 邮箱 | vividairun@gmail.com |
## ⭐ Star History
<!-- 建好 GitHub 仓库后,取消下面这行注释并把 OWNER 换成你的用户名即可显示趋势图: -->
<!-- [![Star History Chart](https://api.star-history.com/svg?repos=OWNER/image2api&type=Date)](https://star-history.com/#OWNER/image2api&Date) -->
觉得有用就点个 ⭐ 吧 —— 建好仓库后取消上方注释即可展示 Star 趋势图。
## 📄 License
本项目(前端 + 后端)基于 [MIT](LICENSE) 协议开源,可自由使用、修改、商用与二次分发。
+18 -4
View File
@@ -1,8 +1,8 @@
# Vivid AI backend config template. Copy to .env and fill in real values.
# image2api backend config template. Copy to .env and fill in real values.
# Real environment variables override .env.
APP_ENV=development
APP_TITLE=Vivid AI
APP_TITLE=image2api
HTTP_ADDR=:6666
# PostgreSQL (database must already exist; tables auto-migrate on boot)
@@ -13,8 +13,22 @@ REDIS_ADDR=127.0.0.1:6379
REDIS_PASSWORD=
REDIS_DB=0
# RustFS / S3-compatible object storage (generated media + reference uploads)
RUSTFS_ENDPOINT=http://127.0.0.1:9000
RUSTFS_BUCKET=vivid-ai
RUSTFS_ACCESS_KEY=YOUR_AK
RUSTFS_SECRET_KEY=YOUR_SK
# Channel upstream API keys are stored AES-GCM encrypted with this secret.
# Set a random value in production and keep it stable — changing it makes
# previously saved channel keys undecryptable.
CHANNEL_SECRET=change-me-channel-secret
# Frontend dev origins allowed for CORS
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Optional: override generated media root (defaults to ../../ai-gateway/data/generated)
# GENERATED_ROOT=
# Set to true when serving the site over HTTPS (session cookie Secure flag)
COOKIE_SECURE=false
# SESSION_COOKIE_NAME=vivid_session
# GENERATED_ROOT= # optional: override generated media root
+19 -47
View File
@@ -1,55 +1,27 @@
# Vivid AI Backend
# image2api backend
Go backend for `vivid-ai`, using:
Go backend for `image2api` — a vivid-protocol gateway that routes generation
jobs across same-protocol upstream channels. Stack: Gin · GORM (PostgreSQL) ·
go-redis.
- Gin
- GORM
- PostgreSQL
- Redis
## Architecture sketch
## Current scope
This is an in-progress rewrite. The current skeleton already includes:
- app bootstrap
- PostgreSQL and Redis initialization
- GORM auto-migrations
- session storage in Redis
- image access control for `/images/:user/:name`
- public site endpoint: `/admin/api/site`
- public showcase endpoint: `/admin/api/showcase`
- session-based auth endpoint: `/admin/api/auth/me`
- `internal/upstream` — vivid-protocol client (NDJSON generate + jobId resume,
`/v1/uploads`, `/v1/models`, `/v1/balance`, error classification)
- `internal/service/channel.go` — channel CRUD, upstream model sync (save =
full sync), AES-GCM key storage
- `internal/service/v1.go` — channel-weighted dispatch, billing, job lifecycle,
stale-job resume
- `internal/http/handler/v1.go` — public vivid endpoints
(`/v1/generate` `/v1/uploads` `/v1/models` `/v1/balance`)
## Environment
Set these before running:
See `.env.example`. Required: `POSTGRES_DSN`, `REDIS_ADDR`, `RUSTFS_*`.
Important: `CHANNEL_SECRET` encrypts stored channel API keys (AES-GCM) — set a
random value and keep it stable; rotating it makes saved keys undecryptable.
```powershell
$env:POSTGRES_DSN="host=127.0.0.1 user=postgres password=postgres dbname=vivid_ai port=5432 sslmode=disable TimeZone=Asia/Shanghai"
$env:REDIS_ADDR="127.0.0.1:6379"
$env:HTTP_ADDR=":6061"
```bash
cp .env.example .env # fill in values
go build -o bin/api ./cmd/api && ./bin/api
```
Optional:
```powershell
$env:APP_ENV="development"
$env:APP_TITLE="Vivid AI"
$env:SESSION_COOKIE_NAME="vivid_session"
$env:CORS_ORIGINS="http://localhost:5173,http://127.0.0.1:5173"
```
## Run
```powershell
go run ./cmd/api
```
## Notes
- Generated media defaults to `../../ai-gateway/data/generated` relative to the backend working directory.
- Private images require either:
- session cookie
- bearer session token
- bearer API key
- Showcase images are public.
+5 -1
View File
@@ -72,6 +72,10 @@ func NewApp(ctx context.Context) (*App, error) {
if err := db.WithContext(ctx).Exec(`ALTER TABLE model_configs DROP COLUMN IF EXISTS enabled`).Error; err != nil {
return nil, fmt.Errorf("drop model_configs.enabled: %w", err)
}
// 首页展示位不再支持 bento,清掉遗留记录。
if err := db.WithContext(ctx).Exec(`DELETE FROM showcase_items WHERE kind = 'bento'`).Error; err != nil {
return nil, fmt.Errorf("purge bento showcase: %w", err)
}
if err := seedDefaults(ctx, db); err != nil {
return nil, fmt.Errorf("seed defaults: %w", err)
}
@@ -118,7 +122,7 @@ func NewApp(ctx context.Context) (*App, error) {
// rows when CHANNEL_SECRET isn't set.
channelSecret = cfg.PostgresDSN
}
channelSvc := service.NewChannelService(channelRepo, modelRepo, upClient, channelSecret)
channelSvc := service.NewChannelService(channelRepo, modelRepo, eventRepo, concSvc, upClient, channelSecret)
// Sweep provider-era model rows: anything not bound to a channel can never
// be routed under the channel scheduler.
if n, err := channelSvc.PurgeOrphanModels(ctx); err != nil {
+16 -1
View File
@@ -7,6 +7,7 @@ import (
"time"
"backend/internal/model"
"backend/internal/repo"
"backend/internal/service"
"github.com/gin-gonic/gin"
)
@@ -99,7 +100,21 @@ func (h *AdminReadHandler) Logs(c *gin.Context) {
userIDs = ids
}
items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, since, "", userIDs, strings.TrimSpace(c.Query("q")), "", c.Query("source"), false, false, false)
// ?kw= — 单搜索框:命中 提示词/模型/错误,或命中 用户(名称/邮箱/ID) 的记录都保留。
var items []model.EventLog
var total int64
var stats *repo.EventStats
var err error
if kw := strings.TrimSpace(c.Query("kw")); kw != "" {
kwIDs, uerr := h.admin.MatchUserIDs(c.Request.Context(), kw)
if uerr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
return
}
items, total, stats, err = h.admin.LogsKeyword(c.Request.Context(), limit, offset, kind, status, since, kw, kwIDs, c.Query("source"))
} else {
items, total, stats, err = h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, since, "", userIDs, strings.TrimSpace(c.Query("q")), "", c.Query("source"), false, false, false)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"})
return
+19 -18
View File
@@ -88,6 +88,25 @@ func (h *ChannelAdminHandler) FetchModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": items})
}
// SyncModels manually re-runs the full upstream model sync on a saved channel
// (new models bound, disappeared ones unbound, orphans deleted). Fetch
// failures are errors here — unlike the save path which degrades to a warning.
func (h *ChannelAdminHandler) SyncModels(c *gin.Context) {
var body struct {
ID string `json:"id"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "channel id required"})
return
}
msg, err := h.channels.SyncModels(c.Request.Context(), body.ID)
if err != nil {
writeUpstreamErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "message": msg})
}
// ImportModels binds the ticked upstream models to the channel (creating local
// model rows on first import).
func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
@@ -106,24 +125,6 @@ func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// UnbindModel detaches one upstream model from a channel; the local model row
// is deleted too when no other channel still binds it.
func (h *ChannelAdminHandler) UnbindModel(c *gin.Context) {
var body struct {
ID string `json:"id"`
Model string `json:"model"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" || strings.TrimSpace(body.Model) == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "id and model required"})
return
}
if err := h.channels.UnbindModel(c.Request.Context(), body.ID, body.Model); err != nil {
writeUpstreamErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// writeUpstreamErr translates upstream-protocol errors into sensible admin
// responses (auth → 400 with the upstream's message, temporary → 502).
func writeUpstreamErr(c *gin.Context, err error) {
+3 -3
View File
@@ -46,16 +46,16 @@ func (h *ShowcaseHandler) List(c *gin.Context) {
// AdminList is the paginated flat view for the admin 首页内容 page — the public
// List keeps its grouped shape for the home page, this one adds kind filter +
// limit/offset/total. Order mirrors the old client flattening: hero → bento → work.
// limit/offset/total. Order mirrors the old client flattening: hero → work.
func (h *ShowcaseHandler) AdminList(c *gin.Context) {
grouped, err := h.showcase.Grouped(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load showcase"})
return
}
kindFilter := c.Query("kind") // "" | hero | bento | work
kindFilter := c.Query("kind") // "" | hero | work
flat := make([]gin.H, 0, 16)
for _, kind := range []string{"hero", "bento", "work"} {
for _, kind := range []string{"hero", "work"} {
if kindFilter != "" && kind != kindFilter {
continue
}
+1 -1
View File
@@ -146,8 +146,8 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
authed.POST("/channels", handlers.ChannelAdmin.Save)
authed.POST("/channels/delete", handlers.ChannelAdmin.Delete)
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
authed.POST("/channels/sync-models", handlers.ChannelAdmin.SyncModels)
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
authed.POST("/channels/unbind-model", handlers.ChannelAdmin.UnbindModel)
authed.GET("/providers", handlers.AdminRead.Providers)
authed.GET("/images", handlers.AdminRead.Images)
authed.DELETE("/images", handlers.AdminRead.DeleteImage)
+6 -1
View File
@@ -5,6 +5,7 @@ import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type User struct {
@@ -160,6 +161,10 @@ type ModelConfig struct {
// successful generation. Independent of the event_log (which is subject to
// retention / manual clearing), so the admin "次数" is a true running total.
GenerationCount int64 `gorm:"not null;default:0"`
// DeletedAt soft-deletes the row (vivid parity): hidden from every normal
// path, but the row + its counters survive so a re-import revives it
// without losing generation_count or pricing.
DeletedAt gorm.DeletedAt `gorm:"index"`
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -194,7 +199,7 @@ type Channel struct {
BaseURL string `gorm:"size:500;not null"`
APIKeyEnc string `gorm:"type:text;not null"` // AES-GCM encrypted upstream API key
Enabled bool `gorm:"not null;default:true;index"`
// Concurrency caps simultaneous in-flight jobs on this channel. 0 = default.
// Concurrency caps simultaneous in-flight jobs on this channel. 0 = unlimited.
Concurrency int `gorm:"not null;default:0"`
// Weight biases weighted-random channel order: weight 2 is picked first at
// roughly 2x the rate of weight 1.
+22 -1
View File
@@ -183,6 +183,26 @@ func (r *ChannelRepository) RoutableModelIDs(ctx context.Context) (map[string]st
return out, err
}
// ChannelNamesByModel returns model_id -> bound channel display names in one
// JOIN query — used by the admin models table to annotate each row with the
// channel(s) it was synced from.
func (r *ChannelRepository) ChannelNamesByModel(ctx context.Context) (map[string][]string, error) {
var rows []struct {
ModelID string `gorm:"column:model_id"`
Name string `gorm:"column:name"`
}
err := r.db.WithContext(ctx).
Table("channel_models").
Select("channel_models.model_id, channels.name").
Joins("JOIN channels ON channels.id = channel_models.channel_id").
Scan(&rows).Error
out := make(map[string][]string, len(rows))
for _, row := range rows {
out[row.ModelID] = append(out[row.ModelID], row.Name)
}
return out, err
}
// ChannelIDsByModel lists channel ids bound to a model (any enabled state).
func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID string) ([]string, error) {
var ids []string
@@ -192,13 +212,14 @@ func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID strin
}
// OrphanModelIDs returns local model ids that no channel binds anymore.
// Soft-deleted rows are skipped — they're already gone from every live path.
func (r *ChannelRepository) OrphanModelIDs(ctx context.Context) ([]string, error) {
var ids []string
err := r.db.WithContext(ctx).
Table("model_configs").
Select("model_configs.id").
Joins("LEFT JOIN channel_models ON channel_models.model_id = model_configs.id").
Where("channel_models.model_id IS NULL").
Where("channel_models.model_id IS NULL AND model_configs.deleted_at IS NULL").
Scan(&ids).Error
return ids, err
}
+29 -1
View File
@@ -24,6 +24,7 @@ type EventListFilter struct {
UserID string
UserIDs []string // when set, keep ONLY rows whose user_id is in this list (admin 用户搜索)
Query string // free-text search over prompt / model / error (server-side, 跨页)
KeywordUserIDs []string // with Query set: also match rows whose user_id is in this list (单搜索框: 用户 OR 内容)
ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page)
Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型)
HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media)
@@ -66,8 +67,12 @@ func (r *EventRepository) List(ctx context.Context, filter EventListFilter) ([]m
}
if term := strings.TrimSpace(filter.Query); term != "" {
like := "%" + term + "%"
if len(filter.KeywordUserIDs) > 0 {
q = q.Where("(prompt ILIKE ? OR model ILIKE ? OR error ILIKE ? OR user_id IN ?)", like, like, like, filter.KeywordUserIDs)
} else {
q = q.Where("(prompt ILIKE ? OR model ILIKE ? OR error ILIKE ?)", like, like, like)
}
}
if filter.ExcludeSource != "" {
q = q.Where("(source IS NULL OR source <> ?)", filter.ExcludeSource)
}
@@ -184,7 +189,7 @@ type DashboardWindow struct {
Pending int64 `json:"pending"`
Image int64 `json:"image"`
Video int64 `json:"video"`
API int64 `json:"api"` // source = 'v1' (OpenAI-compatible key)
API int64 `json:"api"` // source = 'v1' (vk_ external API key)
Web int64 `json:"web"` // everything else (web / playground)
Spent float64 `json:"spent"`
}
@@ -668,6 +673,29 @@ func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID, ac
Updates(map[string]any{"account_id": accountID, "account_email": accountEmail}).Error
}
// FailedByAccount counts failed events grouped by account_id (channel id) —
// the channel list's 失败数 column.
func (r *EventRepository) FailedByAccount(ctx context.Context) (map[string]int64, error) {
type row struct {
AccountID string `gorm:"column:account_id"`
Count int64 `gorm:"column:count"`
}
var rows []row
if err := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Select("account_id, COUNT(*) AS count").
Where("status = ? AND account_id <> ''", "failed").
Group("account_id").
Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]int64, len(rows))
for _, item := range rows {
out[item.AccountID] = item.Count
}
return out, nil
}
// InFlightByAccount counts pending (in-flight) events grouped by account_id, for
// the accounts view's live "in-flight" column.
func (r *EventRepository) InFlightByAccount(ctx context.Context) (map[string]int64, error) {
+34 -2
View File
@@ -44,6 +44,16 @@ func (r *ModelRepository) Get(ctx context.Context, modelID string) (*model.Model
return &item, nil
}
// GetAny includes soft-deleted rows — the import/revive path and orphan
// cleanup need to see rows normal queries hide.
func (r *ModelRepository) GetAny(ctx context.Context, modelID string) (*model.ModelConfig, error) {
var item model.ModelConfig
if err := r.db.WithContext(ctx).Unscoped().First(&item, "(alias <> '' AND alias = ?) OR (alias = '' AND id = ?)", modelID, modelID).Error; err != nil {
return nil, err
}
return &item, nil
}
func (r *ModelRepository) NameMap(ctx context.Context) (map[string]string, error) {
items, err := r.List(ctx)
if err != nil {
@@ -72,18 +82,40 @@ func (r *ModelRepository) Create(ctx context.Context, item *model.ModelConfig) e
}
func (r *ModelRepository) Update(ctx context.Context, modelID string, patch map[string]any) (*model.ModelConfig, error) {
return r.update(ctx, modelID, patch, false)
}
// UpdateAny can reach soft-deleted rows (revive path: patch deleted_at=NULL).
func (r *ModelRepository) UpdateAny(ctx context.Context, modelID string, patch map[string]any) (*model.ModelConfig, error) {
return r.update(ctx, modelID, patch, true)
}
func (r *ModelRepository) update(ctx context.Context, modelID string, patch map[string]any, anyState bool) (*model.ModelConfig, error) {
patch["updated_at"] = time.Now()
if err := r.db.WithContext(ctx).Model(&model.ModelConfig{}).Where("id = ?", modelID).Updates(patch).Error; err != nil {
q := r.db.WithContext(ctx)
if anyState {
q = q.Unscoped()
}
if err := q.Model(&model.ModelConfig{}).Where("id = ?", modelID).Updates(patch).Error; err != nil {
return nil, err
}
var item model.ModelConfig
if err := r.db.WithContext(ctx).First(&item, "id = ?", modelID).Error; err != nil {
if err := q.First(&item, "id = ?", modelID).Error; err != nil {
return nil, err
}
return &item, nil
}
// Delete soft-deletes the model (gorm.DeletedAt): the row keeps its counters
// and pricing so re-importing it later revives the same record.
func (r *ModelRepository) Delete(ctx context.Context, modelID string) (int64, error) {
res := r.db.WithContext(ctx).Delete(&model.ModelConfig{}, "id = ?", modelID)
return res.RowsAffected, res.Error
}
// DeleteHard physically removes the row — only for legacy/provider-era rows
// that must not linger as soft-deleted junk.
func (r *ModelRepository) DeleteHard(ctx context.Context, modelID string) (int64, error) {
res := r.db.WithContext(ctx).Unscoped().Delete(&model.ModelConfig{}, "id = ?", modelID)
return res.RowsAffected, res.Error
}
-1
View File
@@ -63,7 +63,6 @@ func (r *ShowcaseRepository) Grouped(ctx context.Context) (map[string][]model.Sh
grouped := map[string][]model.ShowcaseItem{
"hero": {},
"bento": {},
"work": {},
}
for _, item := range items {
+12
View File
@@ -85,6 +85,7 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
return nil, err
}
routable, _ := s.channels.RoutableModelIDs(ctx)
channelNames, _ := s.channels.ChannelNamesByModel(ctx)
out := make([]map[string]any, 0, len(items))
for _, item := range items {
_, bound := routable[item.ID]
@@ -94,6 +95,7 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
"type": item.Type,
"name": item.Name,
"provider": item.Provider,
"channels": channelNames[item.ID],
// 配价即上架(无启停开关):全档位有价 + 绑定了启用渠道才算可售。
"sellable": bound && ModelFullyPriced(&item),
"ratios": repo.JSONStrings(item.Ratios),
@@ -122,6 +124,15 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
}
func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID string, userIDs []string, query, excludeSource, source string, hasFile, excludeShowcase, mediaOnly bool) ([]model.EventLog, int64, *repo.EventStats, error) {
return s.logs(ctx, limit, offset, kind, status, statuses, since, userID, userIDs, nil, query, excludeSource, source, hasFile, excludeShowcase, mediaOnly)
}
// LogsKeyword 单搜索框:keyword 命中 提示词/模型/错误,或 user_id 在 kwUserIDs 内(名称/邮箱/ID 命中)即保留。
func (s *AdminReadService) LogsKeyword(ctx context.Context, limit, offset int, kind, status string, since *time.Time, keyword string, kwUserIDs []string, source string) ([]model.EventLog, int64, *repo.EventStats, error) {
return s.logs(ctx, limit, offset, kind, status, nil, since, "", nil, kwUserIDs, keyword, "", source, false, false, false)
}
func (s *AdminReadService) logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID string, userIDs, kwUserIDs []string, query, excludeSource, source string, hasFile, excludeShowcase, mediaOnly bool) ([]model.EventLog, int64, *repo.EventStats, error) {
var excludeFiles []string
if excludeShowcase {
excludeFiles = s.showcaseFileList(ctx)
@@ -136,6 +147,7 @@ func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, st
UserID: userID,
UserIDs: userIDs,
Query: query,
KeywordUserIDs: kwUserIDs,
ExcludeSource: excludeSource,
Source: source,
HasFile: hasFile,
+7 -5
View File
@@ -292,7 +292,7 @@ func (s *AdminWriteService) DeleteUserAPIKey(ctx context.Context, userID, keyID
func (s *AdminWriteService) CreateShowcase(ctx context.Context, body map[string]any) (*model.ShowcaseItem, error) {
kind := normalizedShowcaseKind(stringValue(body["kind"]))
if kind == "" {
return nil, errors.New("kind must be hero, bento or work")
return nil, errors.New("kind must be hero or work")
}
image := strings.TrimSpace(stringValue(body["image"]))
if image == "" {
@@ -333,7 +333,7 @@ func (s *AdminWriteService) UpdateShowcase(ctx context.Context, entryID string,
if _, ok := body["kind"]; ok {
kind := normalizedShowcaseKind(stringValue(body["kind"]))
if kind == "" {
return nil, errors.New("kind must be hero, bento or work")
return nil, errors.New("kind must be hero or work")
}
patch["kind"] = kind
}
@@ -402,6 +402,8 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
MaxReferenceImages: intValue(body["max_reference_images"]),
MaxReferenceVideos: intValue(body["max_reference_videos"]),
MaxReferenceAudios: intValue(body["max_reference_audios"]),
MaxRefVideoSeconds: intValue(body["max_ref_video_seconds"]),
MaxRefAudioSeconds: intValue(body["max_ref_audio_seconds"]),
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
Weight: intValue(body["weight"]),
CreatedAt: time.Now(),
@@ -579,7 +581,7 @@ func normalizedStatus(status string) string {
func normalizedShowcaseKind(kind string) string {
switch strings.TrimSpace(kind) {
case "hero", "bento", "work":
case "hero", "work":
return strings.TrimSpace(kind)
default:
return ""
@@ -653,8 +655,8 @@ func boolValueWithDefault(v any, fallback bool) bool {
// resolutionsFromPrices mirrors Python models_store._resolutions_from_prices:
// an image model's quality tiers ARE its price keys (the admin form never sends
// `resolutions` for images), returned in canonical 1K/2K/4K order. gpt-image-2,
// for example, only ever has a "1K" price, so it resolves to exactly ["1K"].
// `resolutions` for images), returned in canonical 1K/2K/4K order. A model that
// only ever has a "1K" price, for example, resolves to exactly ["1K"].
func resolutionsFromPrices(prices datatypes.JSONMap) datatypes.JSON {
out := []string{}
for _, r := range []string{"1K", "2K", "4K"} {
+93 -57
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"io"
"math"
"strings"
"sync"
"time"
@@ -29,13 +30,15 @@ import (
type ChannelService struct {
channels *repo.ChannelRepository
models *repo.ModelRepository
events *repo.EventRepository
conc *ConcurrencyService
up *upstream.Client
secret []byte
}
func NewChannelService(channels *repo.ChannelRepository, models *repo.ModelRepository, up *upstream.Client, secret string) *ChannelService {
func NewChannelService(channels *repo.ChannelRepository, models *repo.ModelRepository, events *repo.EventRepository, conc *ConcurrencyService, up *upstream.Client, secret string) *ChannelService {
sum := sha256.Sum256([]byte("image2api-channel-keys|" + secret))
return &ChannelService{channels: channels, models: models, up: up, secret: sum[:]}
return &ChannelService{channels: channels, models: models, events: events, conc: conc, up: up, secret: sum[:]}
}
// ChannelView is the admin list row: local stats + bound model names + a live
@@ -49,6 +52,10 @@ type ChannelView struct {
Concurrency int `json:"concurrency"`
Weight int `json:"weight"`
Models []string `json:"models"`
// RunningCount 运行中 = Redis 并发槽位实时计数(与调度器同源);
// FailCount 失败数 = event_logs 实时聚合(channel 数少,无需预计算)。
RunningCount int64 `json:"running_count"`
FailCount int64 `json:"fail_count"`
SuccessTotal int64 `json:"success_total"`
UsedCredits float64 `json:"used_credits"`
UpstreamBalance *int64 `json:"upstream_balance"`
@@ -62,6 +69,7 @@ func (s *ChannelService) List(ctx context.Context) ([]ChannelView, error) {
if err != nil {
return nil, err
}
fails, _ := s.events.FailedByAccount(ctx)
out := make([]ChannelView, len(items))
var wg sync.WaitGroup
for i, ch := range items {
@@ -72,6 +80,8 @@ func (s *ChannelService) List(ctx context.Context) ([]ChannelView, error) {
Enabled: ch.Enabled,
Concurrency: ch.Concurrency,
Weight: ch.Weight,
RunningCount: int64(s.conc.Count(ctx, "conc:c:"+ch.ID)),
FailCount: fails[ch.ID],
SuccessTotal: ch.SuccessTotal,
UsedCredits: ch.UsedCredits,
CreatedAt: ch.CreatedAt.Unix(),
@@ -132,6 +142,9 @@ func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Cha
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
return nil, "", errors.New("base_url must start with http:// or https://")
}
if d.Concurrency < 0 || d.Concurrency > math.MaxInt32 {
return nil, "", errors.New("并发数需在 0-2147483647 之间(0 = 不限制)")
}
weight := d.Weight
if weight < 1 {
weight = 1
@@ -159,7 +172,7 @@ func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Cha
if err := s.channels.Create(ctx, ch); err != nil {
return nil, "", err
}
return ch, s.syncChannelModels(ctx, ch.ID), nil
return ch, syncWarn(s.syncChannelModels(ctx, ch.ID)), nil
}
patch := map[string]any{
"name": name,
@@ -179,31 +192,58 @@ func (s *ChannelService) Save(ctx context.Context, d SaveChannelDto) (*model.Cha
if err != nil {
return nil, "", err
}
return ch, s.syncChannelModels(ctx, d.ID), nil
return ch, syncWarn(s.syncChannelModels(ctx, d.ID)), nil
}
// syncWarn folds the sync error into the save-path warning (vivid parity: a
// failed upstream fetch degrades to a non-fatal warning instead of blocking
// the channel save).
func syncWarn(warn string, err error) string {
if err != nil {
return err.Error()
}
return warn
}
// SyncModels manually triggers a full upstream-model sync for a saved channel
// (same truth semantics as the save path). Unlike Save, an unreachable upstream
// or empty model list is a hard error, not a warning.
func (s *ChannelService) SyncModels(ctx context.Context, channelID string) (string, error) {
if _, err := s.channels.Get(ctx, strings.TrimSpace(channelID)); err != nil {
return "", err
}
warn, err := s.syncChannelModels(ctx, channelID)
if err != nil {
return "", err
}
if warn != "" {
return warn, nil
}
return "已同步上游模型", nil
}
// syncChannelModels runs after Save: upstream /v1/models is the truth — every
// sellable upstream model is upserted locally (alias = upstream id) and bound;
// bindings on this channel not in the upstream list are removed, and orphan
// local models are deleted. An unreachable upstream or empty/anomalous list
// only produces a warning — the DB is left untouched.
func (s *ChannelService) syncChannelModels(ctx context.Context, channelID string) string {
// local models are deleted. An unreachable upstream or empty list is an error
// (the save path degrades it to a warning); warn carries non-fatal skips.
func (s *ChannelService) syncChannelModels(ctx context.Context, channelID string) (string, error) {
ch, err := s.channels.Get(ctx, channelID)
if err != nil {
return "模型自动同步失败:" + err.Error()
return "", fmt.Errorf("模型同步失败:%w", err)
}
key := s.decrypt(ch.APIKeyEnc)
if key == "" {
return "模型自动同步失败:无法解密上游密钥"
return "", errors.New("模型同步失败:无法解密上游密钥")
}
fctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
models, err := s.up.Models(fctx, ch.BaseURL, key)
if err != nil {
return "模型自动同步失败:" + err.Error()
return "", fmt.Errorf("模型同步失败:%w", err)
}
if len(models) == 0 {
return "上游未返回任何模型,已跳过同步"
return "", errors.New("上游未返回任何模型,已跳过同步")
}
specs := make([]ChannelModelSpec, 0, len(models))
upstreamIDs := make([]string, 0, len(models))
@@ -218,13 +258,13 @@ func (s *ChannelService) syncChannelModels(ctx context.Context, channelID string
upstreamIDs = append(upstreamIDs, spec.ID)
}
if len(upstreamIDs) == 0 {
return fmt.Sprintf("上游模型均规格异常,已跳过同步:%s", strings.Join(skipped, ";"))
return "", fmt.Errorf("上游模型均规格异常,已跳过同步:%s", strings.Join(skipped, ";"))
}
if err := s.ImportModels(ctx, channelID, specs); err != nil {
return "模型自动同步失败:" + err.Error()
return "", fmt.Errorf("模型同步失败:%w", err)
}
if err := s.channels.UnbindExcept(ctx, channelID, upstreamIDs); err != nil {
return "模型自动同步失败:" + err.Error()
return "", fmt.Errorf("模型同步失败:%w", err)
}
if orphans, oerr := s.channels.OrphanModelIDs(ctx); oerr == nil {
for _, mid := range orphans {
@@ -232,9 +272,9 @@ func (s *ChannelService) syncChannelModels(ctx context.Context, channelID string
}
}
if len(skipped) > 0 {
return fmt.Sprintf("已同步上游模型,跳过 %d 个异常项:%s", len(skipped), strings.Join(skipped, ";"))
return fmt.Sprintf("已同步上游模型,跳过 %d 个异常项:%s", len(skipped), strings.Join(skipped, ";")), nil
}
return ""
return "", nil
}
// normalizeRemoteModel validates an upstream /v1/models item into an import
@@ -259,6 +299,13 @@ func normalizeRemoteModel(m upstream.RemoteModel) (ChannelModelSpec, error) {
default:
return spec, fmt.Errorf("未知类型 %q", spec.Kind)
}
if spec.Duration != nil {
switch spec.Duration.Mode {
case "options", "range":
default:
return spec, errors.New("时长模式仅支持 options / range")
}
}
return spec, nil
}
@@ -323,37 +370,6 @@ func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]u
return items, nil
}
// UnbindModel detaches one upstream model from a channel; when the local model
// row ends up bound to no channel at all it is deleted too (same orphan
// semantics as deleting a whole channel).
func (s *ChannelService) UnbindModel(ctx context.Context, channelID, upstreamModelID string) error {
upID := strings.TrimSpace(upstreamModelID)
if upID == "" {
return errors.New("model id required")
}
m, err := s.channels.UpstreamModelMap(ctx, channelID)
if err != nil {
return err
}
var localID string
for mid, up := range m {
if up == upID {
localID = mid
break
}
}
if localID == "" {
return nil // not bound — idempotent
}
if err := s.channels.Unbind(ctx, channelID, localID); err != nil {
return err
}
if rest, _ := s.channels.ChannelIDsByModel(ctx, localID); len(rest) == 0 {
_, _ = s.models.Delete(ctx, localID)
}
return nil
}
// PurgeOrphanModels sweeps model_configs rows left over from the provider era:
// an unbound model can never be routed, so any row that didn't come through a
// channel import (provider != "channel") is deleted at startup. Channel-typed
@@ -365,11 +381,12 @@ func (s *ChannelService) PurgeOrphanModels(ctx context.Context) (int, error) {
}
n := 0
for _, id := range ids {
m, gerr := s.models.Get(ctx, id)
m, gerr := s.models.GetAny(ctx, id)
if gerr != nil || m == nil || m.Provider == "channel" {
continue
}
if _, derr := s.models.Delete(ctx, id); derr == nil {
// provider 时代残留行没有复活路径,直接物理删除,不留软删垃圾。
if _, derr := s.models.DeleteHard(ctx, id); derr == nil {
n++
}
}
@@ -414,19 +431,38 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
if err != nil {
return err
}
if len(specs) > 50 {
return errors.New("单次最多导入 50 个模型")
}
seenAliases := make(map[string]string, len(specs)) // localID → 上游 id,别名冲突当场拒绝
for _, spec := range specs {
upID := strings.TrimSpace(spec.ID)
if upID == "" {
continue
}
localID := strings.TrimSpace(spec.Alias)
if spec.Duration != nil && spec.Duration.Mode != "options" && spec.Duration.Mode != "range" {
return fmt.Errorf("模型 %s:时长模式仅支持 options / range", upID)
}
alias := strings.TrimSpace(spec.Alias)
if alias == upID {
return fmt.Errorf("「%s」别名不能与上游模型同名,留空即可", upID)
}
localID := alias
if localID == "" {
localID = upID
}
existing, gerr := s.models.Get(ctx, localID)
if prev, dup := seenAliases[localID]; dup && prev != upID {
return fmt.Errorf("别名 %s 被多个上游模型复用", localID)
}
seenAliases[localID] = upID
// GetAny: 软删行也纳入——重导同一模型时复活原行,计数/定价不丢。
existing, gerr := s.models.GetAny(ctx, localID)
if gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound) {
existing = nil
}
if existing != nil && existing.Type != "" && spec.Kind != "" && existing.Type != spec.Kind {
return fmt.Errorf("模型 %s 已存在且类型不同(%s)", localID, existing.Type)
}
var local *model.ModelConfig
if existing != nil && existing.ID == localID {
local = existing
@@ -447,7 +483,10 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
return fmt.Errorf("model %s: %w", localID, err)
}
} else {
if _, err := s.models.Update(ctx, localID, patch); err != nil {
if local.DeletedAt.Valid {
patch["deleted_at"] = nil // revive
}
if _, err := s.models.UpdateAny(ctx, localID, patch); err != nil {
return fmt.Errorf("model %s: %w", localID, err)
}
}
@@ -459,8 +498,8 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
}
// modelPatchFromSpec maps an upstream model spec onto model_configs columns.
// Prices/enabled/alias are intentionally untouched on update so admin pricing
// survives re-import.
// Prices/alias are intentionally untouched on update so admin pricing survives
// re-import.
func modelPatchFromSpec(spec ChannelModelSpec, existing *model.ModelConfig) map[string]any {
patch := map[string]any{
"type": spec.Kind,
@@ -477,9 +516,6 @@ func modelPatchFromSpec(spec ChannelModelSpec, existing *model.ModelConfig) map[
}
if d := spec.Duration; d != nil {
switch d.Mode {
case "fixed":
patch["durations"] = jsonArray([]string{fmt.Sprintf("%ds", d.Seconds)})
patch["duration_min"], patch["duration_max"] = int64(0), int64(0)
case "options":
opts := make([]string, 0, len(d.Options))
for _, s := range d.Options {
+13 -13
View File
@@ -64,8 +64,8 @@ const videoGenBudget = 30 * time.Minute
// imageGenBudget caps one image generation end-to-end.
const imageGenBudget = 8 * time.Minute
// defaultChannelConcurrency applies when a channel's Concurrency is 0.
const defaultChannelConcurrency = 5
// Channel concurrency semantics: 0 = unlimited (the Lua slot script passes
// max=0 straight through), matching vivid's channel design.
// maxChannelFailover caps how many channels one request may burn on
// retryable/temporary errors before giving up, so an upstream-wide blip can't
@@ -100,11 +100,11 @@ type V1Service struct {
conc *ConcurrencyService
}
// chanAcquire takes one per-channel slot (capped at max; 0 = default), tagged
// chanAcquire takes one per-channel slot (capped at max; 0 = unlimited), tagged
// with the generation's eventID.
func (s *V1Service) chanAcquire(ctx context.Context, channelID, eventID string, max int) bool {
if max < 1 {
max = defaultChannelConcurrency
if max < 0 {
max = 0
}
return s.conc.Acquire(ctx, "conc:c:"+channelID, max, eventID)
}
@@ -342,8 +342,9 @@ type V1ModelItem struct {
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
}
// V1ModelDur exposes a video model's duration capability: fixed=locked seconds,
// options=discrete list, range=continuous interval.
// V1ModelDur exposes a video model's duration capability: options=discrete
// list (single-choice models too — "fixed" is never emitted), range=continuous
// interval.
type V1ModelDur struct {
Mode string `json:"mode"`
Seconds int64 `json:"seconds,omitempty"`
@@ -433,20 +434,19 @@ func modelDurationSpec(item *model.ModelConfig) *V1ModelDur {
return &V1ModelDur{Mode: "range", Min: int64(item.DurationMin), Max: int64(item.DurationMax)}
}
opts := repo.JSONStrings(item.Durations)
switch len(opts) {
case 0:
if len(opts) == 0 {
return nil
case 1:
return &V1ModelDur{Mode: "fixed", Seconds: int64(parseDurationSeconds(opts[0]))}
default:
}
vals := make([]int64, 0, len(opts))
for _, d := range opts {
if n := parseDurationSeconds(d); n > 0 {
vals = append(vals, int64(n))
}
}
return &V1ModelDur{Mode: "options", Options: vals}
if len(vals) == 0 {
return nil
}
return &V1ModelDur{Mode: "options", Options: vals}
}
// UserBalance — GET /v1/balance. Re-reads the user row so the number is live.
+7 -2
View File
@@ -102,7 +102,7 @@ type RemoteModel struct {
// RemoteDuration mirrors the duration spec of a vivid model item.
type RemoteDuration struct {
Mode string `json:"mode"` // fixed | options | range
Mode string `json:"mode"` // options | range
Seconds int64 `json:"seconds,omitempty"`
Min int64 `json:"min,omitempty"`
Max int64 `json:"max,omitempty"`
@@ -422,8 +422,13 @@ func (c *Client) Download(ctx context.Context, url string) ([]byte, string, erro
if len(data) == 0 {
return nil, "", fmt.Errorf("%w: empty result", ErrRetryable)
}
// Content-Type fallback chain: only trust image/* video/* headers (upstreams
// often mislabel results text/plain or octet-stream), else sniff magic bytes.
ct := strings.TrimSpace(resp.Header.Get("Content-Type"))
if ct == "" || ct == "application/octet-stream" {
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = strings.TrimSpace(ct[:i])
}
if !strings.HasPrefix(ct, "image/") && !strings.HasPrefix(ct, "video/") {
ct = http.DetectContentType(data)
}
return data, ct, nil
+2
View File
@@ -82,6 +82,8 @@ services:
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-vividai_secret_2026}
CORS_ORIGINS: ${CORS_ORIGINS:-http://vividai.run,http://www.vividai.run,http://206.168.190.183}
COOKIE_SECURE: "false"
# 渠道上游 API Key 的 AES-GCM 加密密钥——务必配置随机值;丢失/变更后已存的渠道密钥无法解密
CHANNEL_SECRET: ${CHANNEL_SECRET:-change-me-channel-secret}
volumes:
- generated:/app/data/generated
depends_on:
+2
View File
@@ -72,6 +72,8 @@ services:
RUSTFS_SECRET_KEY: vividai-secret-change-me
CORS_ORIGINS: http://localhost:2000
COOKIE_SECURE: "false"
# 渠道上游 API Key 的 AES-GCM 加密密钥——生产环境务必改成随机值并保持不变
CHANNEL_SECRET: change-me-channel-secret
volumes:
- generated:/app/data/generated
depends_on:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

+8 -24
View File
@@ -1,7 +1,8 @@
# ai-gateway frontend
# image2api frontend
Vue 3 + Vite admin console for the ai-gateway backend. This replaces the old
single-file `static/admin.html`.
Vue 3 + Vite frontend for `image2api`: user-facing generation UI (playground,
gallery, docs, billing) plus the admin console (channels, models, users,
orders, logs, config).
## Develop
@@ -10,27 +11,10 @@ npm install
npm run dev # http://localhost:5173
```
The dev server proxies `/admin`, `/health`, `/generated`, `/v1` to the backend.
Start the backend separately:
The dev server proxies `/admin`, `/health`, `/images`, `/v1` to the backend.
Start the Go backend separately (see `backend/README.md`).
```bash
# from repo root
python app.py # http://0.0.0.0:6060
# from ../backend
go build -o bin/api ./cmd/api && ./bin/api # http://127.0.0.1:6666
```
If the backend runs elsewhere, set `VITE_BACKEND` before `npm run dev`:
```bash
VITE_BACKEND=http://192.168.1.10:6060 npm run dev
```
## Build
```bash
npm run build # outputs static assets to ./dist
npm run preview # serve the production build locally
```
When hosting `dist/` on a different origin than the API, set `VITE_API_BASE`
(e.g. `VITE_API_BASE=http://api-host:6060`) at build time, and add that frontend
origin to the backend's `CORS_ORIGINS` env var.
+12 -12
View File
@@ -1,17 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" fill="none">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="ring" x1="6" y1="6" x2="42" y2="42" gradientUnits="userSpaceOnUse">
<stop stop-color="#e879f9"/><stop offset=".5" stop-color="#8b5cf6"/><stop offset="1" stop-color="#38bdf8"/>
<linearGradient id="bg" x1="8" y1="4" x2="40" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#3f3f46"/>
<stop offset="1" stop-color="#18181b"/>
</linearGradient>
<linearGradient id="v" x1="14" y1="14" x2="34" y2="34" gradientUnits="userSpaceOnUse">
<stop stop-color="#f0abfc"/><stop offset="1" stop-color="#8b5cf6"/>
<linearGradient id="shine" x1="24" y1="3" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity="0.14"/>
<stop offset="0.9" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
</defs>
<g transform="translate(24 24)">
<circle r="18.5" fill="none" stroke="url(#ring)" stroke-width="3.6" stroke-linecap="round"
stroke-dasharray="86 30" transform="rotate(-58)"/>
<path d="M-8 -7.5 L0 9 L8 -7.5" fill="none" stroke="url(#v)" stroke-width="5.2"
stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="13" cy="-13" r="3.6" fill="#38bdf8"/>
</g>
<rect x="2" y="2" width="44" height="44" rx="15" fill="url(#bg)"/>
<rect x="2" y="2" width="44" height="22" rx="15" fill="url(#shine)"/>
<path d="M22 11 Q23.4 22.6 35 24 Q23.4 25.4 22 37 Q20.6 25.4 9 24 Q20.6 22.6 22 11 Z" fill="#fff" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/>
<path d="M35.5 9 Q36.2 13.8 41 14.5 Q36.2 15.2 35.5 20 Q34.8 15.2 30 14.5 Q34.8 13.8 35.5 9 Z" fill="#fff" stroke="#fff" stroke-width="1.4" stroke-linejoin="round" opacity="0.92"/>
<circle cx="36" cy="36" r="2.6" fill="#fb923c"/>
</svg>

Before

Width:  |  Height:  |  Size: 934 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" viewBox="0 0 600 800"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e9d8c4"/><stop offset="1" stop-color="#c9a27e"/></linearGradient></defs><rect width="600" height="800" fill="url(#g)"/><circle cx="492.00000000000006" cy="256" r="60" fill="rgba(255,255,255,0.55)"/><path d="M0 528 Q150 400 300 512 T600 463.99999999999994 V800 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 624 Q180 528 360 624 T600 576 V800 H0Z" fill="rgba(0,0,0,0.16)"/></svg>

After

Width:  |  Height:  |  Size: 547 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" viewBox="0 0 600 800"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfd8c8"/><stop offset="1" stop-color="#8aa38b"/></linearGradient></defs><rect width="600" height="800" fill="url(#g)"/><circle cx="414" cy="256" r="60" fill="rgba(255,255,255,0.55)"/><path d="M0 528 Q150 400 300 512 T600 463.99999999999994 V800 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 624 Q180 528 360 624 T600 576 V800 H0Z" fill="rgba(0,0,0,0.16)"/></svg>

After

Width:  |  Height:  |  Size: 532 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cdd6e0"/><stop offset="1" stop-color="#7f93ad"/></linearGradient></defs><rect width="600" height="600" fill="url(#g)"/><circle cx="336" cy="192" r="60" fill="rgba(255,255,255,0.55)"/><path d="M0 396 Q150 300 300 384 T600 348 V600 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 468 Q180 396 360 468 T600 432 V600 H0Z" fill="rgba(0,0,0,0.16)"/></svg>

After

Width:  |  Height:  |  Size: 517 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800" viewBox="0 0 600 800"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e6c9bd"/><stop offset="1" stop-color="#c07a5f"/></linearGradient></defs><rect width="600" height="800" fill="url(#g)"/><circle cx="558" cy="256" r="60" fill="rgba(255,255,255,0.55)"/><path d="M0 528 Q150 400 300 512 T600 463.99999999999994 V800 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 624 Q180 528 360 624 T600 576 V800 H0Z" fill="rgba(0,0,0,0.16)"/></svg>

After

Width:  |  Height:  |  Size: 532 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e6c9bd"/><stop offset="1" stop-color="#c07a5f"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="656" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>

After

Width:  |  Height:  |  Size: 589 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#dcd6cf"/><stop offset="1" stop-color="#9a9088"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="552" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>

After

Width:  |  Height:  |  Size: 589 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d9cfe0"/><stop offset="1" stop-color="#9a86a8"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="448" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>

After

Width:  |  Height:  |  Size: 589 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfe0dc"/><stop offset="1" stop-color="#6f9c95"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="744" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>

After

Width:  |  Height:  |  Size: 589 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e9d8c4"/><stop offset="1" stop-color="#c9a27e"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="640" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>

After

Width:  |  Height:  |  Size: 589 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfd8c8"/><stop offset="1" stop-color="#8aa38b"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="536" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>

After

Width:  |  Height:  |  Size: 589 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="534" viewBox="0 0 800 534"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cdd6e0"/><stop offset="1" stop-color="#7f93ad"/></linearGradient></defs><rect width="800" height="534" fill="url(#g)"/><circle cx="432" cy="170.88" r="80" fill="rgba(255,255,255,0.55)"/><path d="M0 352.44 Q200 267 400 341.76 T800 309.71999999999997 V534 H0Z" fill="rgba(0,0,0,0.12)"/><path d="M0 416.52000000000004 Q240 352.44 480 416.52000000000004 T800 384.47999999999996 V534 H0Z" fill="rgba(0,0,0,0.16)"/></svg>

After

Width:  |  Height:  |  Size: 589 B

@@ -17,7 +17,7 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
<div class="px-6 py-4 border-b border-b-[color:var(--ink-soft)] flex items-center justify-between gap-3 shrink-0">
<div class="flex items-center gap-2">
<span class="w-7 h-7 rounded-lg text-white grid place-items-center"
style="background: linear-gradient(135deg, #7c5cfc, #22d3ee); box-shadow: 0 4px 12px var(--accent-glow)">
style="background: linear-gradient(135deg, #18181b, #52525b); box-shadow: 0 4px 12px var(--accent-glow)">
<Icon name="spark" class="w-4 h-4" />
</span>
<h2 class="text-base font-extrabold">公告</h2>
@@ -30,7 +30,7 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
<div class="px-6 py-4 border-t border-t-[color:var(--ink-soft)] flex justify-end shrink-0">
<button @click="dismissAnnouncement"
class="rounded-lg text-white border border-white/20 px-5 py-2 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
style="background: linear-gradient(120deg, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">
style="background: linear-gradient(120deg, #18181b, #52525b); box-shadow: 0 8px 20px var(--accent-glow)">
我知道了
</button>
</div>
@@ -55,7 +55,7 @@ const html = computed(() => marked.parse(announcement.content.trim() || '暂无
.ann-body :deep(li) { margin: 0.2em 0; }
.ann-body :deep(a) { color: var(--m-orange); text-decoration: underline; }
.ann-body :deep(strong) { font-weight: 700; color: var(--fg); }
.ann-body :deep(code) { background: rgb(124 92 252 / 0.12); padding: 0.1em 0.35em; border-radius: 0.3rem; font-size: 0.85em; }
.ann-body :deep(code) { background: rgb(24 24 27 / 0.12); padding: 0.1em 0.35em; border-radius: 0.3rem; font-size: 0.85em; }
.ann-body :deep(pre) { background: #111; color: #f5f5f5; padding: 0.8em; border-radius: 0.5rem; overflow-x: auto; margin: 0.6em 0; }
.ann-body :deep(pre code) { background: none; padding: 0; }
.ann-body :deep(blockquote) { border-left: 3px solid var(--m-orange); padding-left: 0.9em; color: var(--fg-3); margin: 0.6em 0; }
+13 -1
View File
@@ -28,12 +28,24 @@ const PATHS = {
music: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
play: '<path d="m6 3 14 9-14 9V3Z"/>',
pause: '<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>',
arrow: '<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>',
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>',
chat: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
layers: '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
key: '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l3-3a1 1 0 0 0 0-1.4l-2.3-2.3a1 1 0 0 0-1.4 0l-3 3a1 1 0 0 0 0 1.4z"/><path d="m15.5 7.5-8 8"/><circle cx="7.5" cy="15.5" r="5.5"/>',
mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/>',
team: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
wallet: '<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/>',
}
defineProps({ name: { type: String, required: true } })
defineProps({
name: { type: String, required: true },
size: { type: [Number, String], default: null },
})
</script>
<template>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
:width="size || undefined" :height="size || undefined"
stroke-linecap="round" stroke-linejoin="round" v-html="PATHS[name] || ''" />
</template>
+11 -16
View File
@@ -161,12 +161,9 @@ async function submit() {
<template>
<transition name="modal">
<div v-if="auth.loginOpen" class="fixed inset-0 z-50 grid place-items-center px-4">
<div class="absolute inset-0 bg-black/70 backdrop-blur-md" @click="closeLogin"></div>
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="closeLogin"></div>
<div class="card relative z-10 w-full max-w-[26rem]">
<!-- ambient aurora washes -->
<div class="pointer-events-none absolute -top-24 -right-16 h-56 w-56 rounded-full bg-[#8b5cf6]/30 blur-3xl"></div>
<div class="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full bg-[#22d3ee]/25 blur-3xl"></div>
<div class="relative p-7">
<button class="absolute top-4 right-4 grid h-7 w-7 place-items-center rounded-lg text-[color:var(--fg-3)] hover:text-[color:var(--fg)] hover:bg-[var(--hover)] transition-colors"
@@ -263,11 +260,9 @@ async function submit() {
<style scoped>
.card {
border-radius: 1.2rem;
background: var(--menu-bg);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
background: #ffffff;
color: var(--fg);
border: 1px solid var(--glass-border);
border: 1px solid rgb(24 24 27 / 0.08);
box-shadow: var(--glass-shadow-lg);
overflow: hidden;
}
@@ -277,7 +272,7 @@ html.dark .card {
.logo-halo {
position: relative;
filter: drop-shadow(0 6px 14px rgb(124 92 252 / 0.4));
filter: drop-shadow(0 6px 14px rgb(24 24 27 / 0.4));
}
/* segmented tab control */
@@ -296,7 +291,7 @@ html.dark .card {
.tab:hover { color: var(--fg); }
.tab-on {
color: #fff;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
background: linear-gradient(120deg, #18181b, #27272a);
border-color: transparent;
box-shadow: 0 4px 14px var(--accent-glow);
}
@@ -317,8 +312,8 @@ html.dark .card {
}
html.dark .fld { background: rgb(255 255 255 / 0.05); }
.fld:focus {
border-color: rgb(124 92 252 / 0.65);
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
border-color: rgb(24 24 27 / 0.65);
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.18);
}
.fld::placeholder { color: var(--fg-faint); }
/* Keep autofill on-theme in dark (otherwise it paints white/yellow). */
@@ -345,11 +340,11 @@ html.dark .fld:-webkit-autofill:focus {
display: inline-flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 800; white-space: nowrap;
color: var(--fg);
background: var(--hover); border: 1px solid rgb(124 92 252 / 0.4);
box-shadow: 0 2px 10px rgb(124 92 252 / 0.14);
background: var(--hover); border: 1px solid rgb(24 24 27 / 0.4);
box-shadow: 0 2px 10px rgb(24 24 27 / 0.14);
transition: background 0.15s, box-shadow 0.15s, transform 0.15s, opacity 0.15s;
}
.code-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 14px rgb(124 92 252 / 0.22); }
.code-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 14px rgb(24 24 27 / 0.22); }
.code-btn:active:not(:disabled) { transform: scale(0.96) translateY(1px); box-shadow: none; }
/* Disabled (sending / countdown): just dim — keep the theme text color so it
never washes out to invisible white on the light card. */
@@ -361,7 +356,7 @@ html.dark .fld:-webkit-autofill:focus {
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
padding: 0.72rem 0; border-radius: 0.65rem; font-size: 0.9rem; font-weight: 800;
letter-spacing: 0.02em; color: white; margin-top: 0.35rem;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee 140%);
background: linear-gradient(120deg, #18181b, #27272a 55%, #52525b 140%);
border: 1px solid rgb(255 255 255 / 0.16);
box-shadow: 0 8px 22px var(--accent-glow);
transition: transform 0.13s, box-shadow 0.13s, filter 0.15s;
+24 -19
View File
@@ -1,8 +1,7 @@
<script setup>
// Vivid mark: an open spectrum ring (lens/aperture) around a play triangle,
// with a shutter dot closing the gap — image + video in one glyph.
// Transparent background so it reads on both the dark and light shells.
// Colors follow the aurora palette (violet / pink / cyan).
// Default mark: an ink squircle holding a soft white sparkle (the "idea"),
// a small companion star and a warm dot — cute, and on-theme with the
// ink-black / warm-paper palette used across the site.
// Each instance gets unique gradient ids (useId) so multiple logos on one
// page don't share/clobber defs.
import { useId } from 'vue'
@@ -10,29 +9,35 @@ import { useId } from 'vue'
defineProps({ size: { type: [Number, String], default: 40 } })
const uid = useId()
const gRing = `vring-${uid}`
const gBg = `vbg-${uid}`
const gShine = `vshine-${uid}`
</script>
<template>
<svg :width="size" :height="size" viewBox="0 0 48 48" fill="none"
xmlns="http://www.w3.org/2000/svg" class="shrink-0">
<defs>
<linearGradient :id="gRing" x1="6" y1="6" x2="42" y2="42" gradientUnits="userSpaceOnUse">
<stop stop-color="#7c5cfc" />
<stop offset="0.5" stop-color="#e879f9" />
<stop offset="1" stop-color="#22d3ee" />
<linearGradient :id="gBg" x1="8" y1="4" x2="40" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#3f3f46" />
<stop offset="1" stop-color="#18181b" />
</linearGradient>
<linearGradient :id="gShine" x1="24" y1="3" x2="24" y2="24" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity="0.14" />
<stop offset="0.9" stop-color="#fff" stop-opacity="0" />
</linearGradient>
</defs>
<g transform="translate(24 24)">
<!-- open ring: dash gap leaves room for the shutter dot -->
<circle r="17.5" fill="none" :stroke="`url(#${gRing})`" stroke-width="4.4"
stroke-linecap="round" stroke-dasharray="84 32" transform="rotate(-58)" />
<!-- play triangle = image + video -->
<path d="M-3 -6.5 L8.5 0 L-3 6.5 Z" fill="#7c5cfc" stroke="#111" stroke-width="1.4"
stroke-linejoin="round" />
<!-- shutter dot -->
<circle cx="13" cy="-13" r="4.2" fill="#22d3ee" stroke="#111" stroke-width="1.6" />
</g>
<!-- squircle tile -->
<rect x="2" y="2" width="44" height="44" rx="15" :fill="`url(#${gBg})`" />
<rect x="2" y="2" width="44" height="22" rx="15" :fill="`url(#${gShine})`" />
<!-- big sparkle -->
<path d="M22 11 Q23.4 22.6 35 24 Q23.4 25.4 22 37 Q20.6 25.4 9 24 Q20.6 22.6 22 11 Z"
fill="#fff" stroke="#fff" stroke-width="2.2" stroke-linejoin="round" />
<!-- small companion star -->
<path d="M35.5 9 Q36.2 13.8 41 14.5 Q36.2 15.2 35.5 20 Q34.8 15.2 30 14.5 Q34.8 13.8 35.5 9 Z"
fill="#fff" stroke="#fff" stroke-width="1.4" stroke-linejoin="round" opacity="0.92" />
<!-- warm dot -->
<circle cx="36" cy="36" r="2.6" fill="#fb923c" />
</svg>
</template>
+10 -6
View File
@@ -167,6 +167,10 @@ async function save() {
duration_prices_agent,
alias: alias.value.trim(),
max_reference_images: e.max_reference_images || 0,
max_reference_videos: e.max_reference_videos || 0,
max_reference_audios: e.max_reference_audios || 0,
max_ref_video_seconds: e.max_ref_video_seconds || 0,
max_ref_audio_seconds: e.max_ref_audio_seconds || 0,
reference_mode: e.reference_mode || 'none',
weight: Number(weight.value) || 0,
}
@@ -221,7 +225,7 @@ async function save() {
<SelectMenu v-if="!isEdit" v-model="selectedId" :options="addOptions"
placeholder="选择一个支持的模型" />
<div v-else class="field font-mono !cursor-default opacity-90">{{ entry?.id }}</div>
<p v-if="!isEdit && !addOptions.length" class="text-[11px] text-amber-300/80 mt-1.5">
<p v-if="!isEdit && !addOptions.length" class="text-[11px] text-amber-600 mt-1.5">
模型经「渠道管理 → 拉取上游模型」导入。
</p>
</div>
@@ -236,8 +240,8 @@ async function save() {
<div v-if="entry" class="rounded-xl bg-white/[0.03] ring-1 ring-white/[0.06] p-3.5 space-y-2.5">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="isVideo ? 'bg-rose-500/10 text-rose-300 ring-rose-400/30'
: 'bg-sky-500/10 text-sky-300 ring-sky-400/30'">
:class="isVideo ? 'bg-rose-500/10 text-rose-600 ring-rose-500/25'
: 'bg-sky-500/10 text-sky-600 ring-sky-500/25'">
{{ isVideo ? '生视频' : '生图' }}
</span>
<span class="text-[11px] text-white/45 capitalize">{{ entry.provider }}</span>
@@ -302,7 +306,7 @@ async function save() {
</div>
<div class="relative flex-1">
<input v-model="imagePricesAgent[r]" type="number" min="0" step="1" class="field !pr-10" placeholder="跟随普通" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-300/40 text-[10px]">代理</span>
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-600 text-[10px]">代理</span>
</div>
</div>
<p v-if="!resolutions.length" class="text-xs text-white/35">该模型未声明分辨率档位</p>
@@ -325,7 +329,7 @@ async function save() {
</div>
<div class="relative flex-1">
<input v-model="videoPricesAgent[d]" type="number" min="0" step="1" class="field !pr-10" placeholder="跟随普通" />
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-300/40 text-[10px]">代理</span>
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-amber-600 text-[10px]">代理</span>
</div>
</div>
</div>
@@ -343,7 +347,7 @@ async function save() {
</div>
</template>
<p v-if="error" class="text-xs text-rose-300">{{ error }}</p>
<p v-if="error" class="text-xs text-rose-600">{{ error }}</p>
<div class="flex justify-end gap-2 pt-1">
<button @click="emit('close')" class="btn-soft">取消</button>
+2 -2
View File
@@ -129,7 +129,7 @@ onUnmounted(clearTimers)
<p class="text-sm text-[color:var(--fg-3)] mt-1">二维码已失效,订单已取消</p>
<p class="text-xs text-[color:var(--fg-faint)] mt-1">如需充值请重新下单</p>
<button @click="finish" class="mt-5 rounded-lg text-white border border-white/20 px-5 py-2 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
style="background: linear-gradient(120deg, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">关闭</button>
style="background: linear-gradient(120deg, #18181b, #52525b); box-shadow: 0 8px 20px var(--accent-glow)">关闭</button>
</div>
<!-- pending -->
@@ -147,7 +147,7 @@ onUnmounted(clearTimers)
<!-- jump (no qrcode): the cashier opened in a new tab — just monitor -->
<template v-else>
<div class="flex flex-col items-center justify-center py-7 mb-2">
<div class="w-12 h-12 rounded-full border-[3px] border-[rgb(124_92_252/0.2)] border-t-[#7c5cfc] animate-spin mb-3"></div>
<div class="w-12 h-12 rounded-full border-[3px] border-[rgb(124_92_252/0.2)] border-t-[#18181b] animate-spin mb-3"></div>
<p class="text-sm font-medium text-[color:var(--fg)]">支付监控中…</p>
<p class="text-xs text-[color:var(--fg-faint)] mt-1">已打开支付页面,完成后自动到账</p>
</div>
+7 -6
View File
@@ -14,6 +14,7 @@ const props = defineProps({
mono: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
up: { type: Boolean, default: false }, // 面板向上展开(底部居中的输入框用)
compact: { type: Boolean, default: false }, // 工具栏用的紧凑尺寸
})
const emit = defineEmits(['update:modelValue'])
@@ -74,8 +75,8 @@ onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
<button type="button" @click="toggle" @keydown="onKeydown"
:aria-expanded="open"
:disabled="disabled"
class="field flex items-center justify-between gap-2 text-left h-10 !py-0 disabled:opacity-50 disabled:cursor-not-allowed"
:class="[mono ? 'font-mono' : '', selected ? '' : 'text-[color:var(--fg-faint)]']">
class="field flex items-center justify-between gap-2 text-left !py-0 disabled:opacity-50 disabled:cursor-not-allowed"
:class="[compact ? 'h-9 !text-[13px] font-semibold min-w-[7.5rem]' : 'h-10', mono ? 'font-mono' : '', selected ? '' : 'text-[color:var(--fg-faint)]']">
<span class="truncate">{{ label }}</span>
<Icon name="chevron"
class="w-4 h-4 shrink-0 text-[color:var(--fg-3)] transition-transform duration-200"
@@ -91,18 +92,18 @@ onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-1">
<div v-if="open" ref="panel"
class="absolute z-30 w-full max-h-64 overflow-auto rounded-xl border border-[color:var(--glass-border)]
bg-[var(--menu-bg)] backdrop-blur-xl p-1.5 shadow-[var(--glass-shadow-lg)]"
class="absolute z-30 w-full min-w-[9rem] max-h-64 overflow-auto rounded-xl border border-[rgb(24_24_27/0.08)]
bg-white p-1.5 shadow-[0_2px_6px_rgb(24_24_27/0.05),0_20px_40px_-12px_rgb(24_24_27/0.22)]"
:class="up ? 'bottom-full mb-2' : 'mt-2'">
<button v-for="(o, i) in options" :key="o.value" type="button"
:data-idx="i" @click="pick(o)" @mouseenter="active = i"
class="w-full flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors"
:class="[
mono ? 'font-mono' : '',
i === active ? 'bg-[var(--m-yellow)] text-[#111]' : 'text-[color:var(--fg-2)]',
i === active ? 'bg-[#f4f4f5] text-[#18181b]' : 'text-[color:var(--fg-2)]',
]">
<span class="truncate">{{ o.label }}</span>
<Icon v-if="o.value === modelValue" name="check" class="w-4 h-4 shrink-0 text-[var(--m-orange)]" />
<Icon v-if="o.value === modelValue" name="check" class="w-4 h-4 shrink-0 text-[#18181b]" />
</button>
<div v-if="!options.length" class="px-3 py-2 text-xs text-[color:var(--fg-3)]">无选项</div>
</div>
+2 -2
View File
@@ -258,7 +258,7 @@ async function recover() {
<div v-if="maxRefs > 0">
<label class="lbl">
{{ refsLabel }}
<span v-if="refsRequired" class="text-rose-300">*</span>
<span v-if="refsRequired" class="text-rose-600">*</span>
</label>
<div class="flex gap-2 flex-wrap items-start">
<div v-for="(img, i) in refImages" :key="i"
@@ -286,7 +286,7 @@ async function recover() {
</button>
<p v-if="status" class="text-xs text-white/55">{{ status }}</p>
<p v-if="error" class="text-xs text-rose-300 break-all">{{ error }}</p>
<p v-if="error" class="text-xs text-rose-600 break-all">{{ error }}</p>
<div v-if="resultUrl" class="rounded-xl ring-1 ring-white/10 bg-white/[0.03] overflow-hidden grid place-items-center min-h-[220px]">
<video v-if="resultKind === 'video'" :src="resultUrl" controls autoplay
@@ -223,14 +223,14 @@ defineExpose({ startEdit, commitNote })
<style scoped>
.cv-node {
position: absolute;
border-radius: 14px;
border: 1px solid var(--glass-border);
border-radius: 16px;
border: 1px solid rgb(24 24 27 / 0.08);
contain: layout style;
user-select: none;
touch-action: none;
cursor: grab;
background: var(--surface-solid);
box-shadow: var(--glass-shadow);
box-shadow: 0 1px 2px rgb(24 24 27 / 0.05), 0 8px 24px -8px rgb(24 24 27 / 0.12);
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.cv-node__clip {
@@ -240,8 +240,8 @@ defineExpose({ startEdit, commitNote })
overflow: hidden;
}
.cv-node.is-selected {
border-color: rgb(124 92 252 / 0.7);
box-shadow: var(--glass-shadow-lg), 0 0 0 3px rgb(124 92 252 / 0.25), 0 10px 30px var(--accent-glow);
border-color: #18181b;
box-shadow: 0 0 0 2px #18181b, 0 14px 34px -10px rgb(24 24 27 / 0.3);
}
.cv-node:active {
cursor: grabbing;
@@ -286,10 +286,10 @@ defineExpose({ startEdit, commitNote })
overflow: hidden;
}
/* 笔记 = 淡黄色贴纸 */
/* 笔记 = 白色卡片 */
.cv-node--note {
background: #fdf6c9;
border-color: rgb(180 140 20 / 0.35);
background: var(--surface-solid);
border-color: rgb(24 24 27 / 0.1);
cursor: text;
}
html.dark .cv-node--note {
@@ -305,7 +305,7 @@ html.dark .cv-node--note {
padding: 12px 14px;
font-size: 32px;
line-height: 1.35;
color: #111;
color: #27272a;
outline: none;
white-space: pre-wrap;
word-break: break-word;
@@ -337,8 +337,8 @@ html.dark .cv-node--note {
justify-content: center;
gap: 10px;
background:
radial-gradient(130% 130% at 15% 0%, rgb(124 92 252 / 0.14), transparent 55%),
radial-gradient(130% 130% at 85% 100%, rgb(34 211 238 / 0.12), transparent 55%),
radial-gradient(130% 130% at 15% 0%, rgb(24 24 27 / 0.14), transparent 55%),
radial-gradient(130% 130% at 85% 100%, rgb(24 24 27 / 0.12), transparent 55%),
var(--surface);
}
.cv-node__gen::after {
@@ -371,7 +371,7 @@ html.dark .cv-node--note {
width: 34px;
height: 34px;
border-radius: 50%;
border: 3px solid rgb(124 92 252 / 0.2);
border: 3px solid rgb(24 24 27 / 0.2);
border-top-color: var(--m-orange);
border-right-color: var(--m-orange-2);
animation: cv-spin 0.9s linear infinite;
@@ -390,7 +390,7 @@ html.dark .cv-node--note {
max-height: 5em;
overflow: auto;
overflow-wrap: anywhere;
text-align: center;
text-align: left;
user-select: text;
}
@@ -404,20 +404,26 @@ html.dark .cv-node--note {
cursor: pointer;
z-index: 3;
touch-action: manipulation;
opacity: 0;
transition: opacity 0.15s ease;
}
.cv-node:hover .cv-port,
.cv-node.is-selected .cv-port,
.cv-port.is-active,
.cv-port:focus-visible { opacity: 1; }
.cv-port::after {
content: '';
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--m-orange);
box-shadow: 0 1px 4px rgb(0 0 0 / 0.35), 0 0 0 2px rgb(255 255 255 / 0.8);
background: var(--surface-solid);
border: 2px solid #18181b;
box-shadow: 0 1px 4px rgb(0 0 0 / 0.25);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.cv-port:hover::after, .cv-port:focus-visible::after, .cv-port.is-active::after {
transform: scale(1.3);
box-shadow: 0 0 0 4px rgb(124 92 252 / 0.25), 0 2px 10px var(--accent-glow);
box-shadow: 0 0 0 4px rgb(24 24 27 / 0.25), 0 2px 10px var(--accent-glow);
}
.cv-port.is-active::after { background: var(--m-orange); }
.cv-port:focus-visible { outline: 2px solid var(--m-orange); outline-offset: -2px; border-radius: 50%; }
+8 -11
View File
@@ -781,8 +781,8 @@ defineExpose({
background-color: var(--app-bg);
background-image: radial-gradient(
circle,
color-mix(in srgb, var(--fg) 16%, transparent) 1.4px,
transparent 1.4px
color-mix(in srgb, var(--fg) 12%, transparent) 1.3px,
transparent 1.3px
);
}
.cv-stage.is-panning {
@@ -808,21 +808,18 @@ defineExpose({
/* 常规连线:蓝色虚线;选中时改紫色实线加粗 */
.cv-edge {
fill: none;
stroke: rgb(95 108 248 / 0.85);
stroke-width: 2.4;
stroke-dasharray: 10 6;
filter: drop-shadow(0 0 5px rgb(124 92 252 / 0.3));
stroke: rgb(113 113 122 / 0.75);
stroke-width: 2;
stroke-dasharray: 8 6;
}
.cv-edge--hot {
stroke: var(--m-orange);
stroke-width: 2.8;
filter: drop-shadow(0 0 7px var(--accent-glow));
stroke-width: 2.6;
}
.cv-edge--sel {
stroke: var(--m-orange);
stroke-width: 3.5;
stroke-dasharray: none;
filter: drop-shadow(0 0 8px var(--accent-glow));
}
.cv-edge-hit {
fill: none;
@@ -843,8 +840,8 @@ defineExpose({
}
.cv-marquee {
position: absolute;
border: 2px dashed rgb(124 92 252 / 0.5);
background: rgb(124 92 252 / 0.08);
border: 2px dashed rgb(24 24 27 / 0.5);
background: rgb(24 24 27 / 0.08);
pointer-events: none;
border-radius: 4px;
}
+21 -50
View File
@@ -1,10 +1,9 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import Icon from '../components/Icon.vue'
import Logo from '../components/Logo.vue'
import { site } from '../site'
import { isDark, toggleTheme } from '../theme'
const route = useRoute()
@@ -27,8 +26,6 @@ const tabs = [
{ label: '系统配置', to: '/admin/config', icon: 'config' },
]
const currentLabel = computed(() => route.meta?.label || '')
// 二级菜单展开状态:当前路由命中子项时默认展开,也可手动切换。
const openGroups = ref(new Set())
function groupActive(t) { return (t.children || []).some((c) => route.path.startsWith(c.to)) }
@@ -47,14 +44,14 @@ watch(() => route.path, () => {
</script>
<template>
<div class="theme-x h-screen flex bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-orange-400/30 overflow-hidden">
<div class="theme-x h-screen flex bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-orange-500/30 overflow-hidden">
<!-- ===== Sidebar ===== -->
<aside class="w-60 shrink-0 border-r border-r-[color:var(--ink-soft)] bg-[var(--surface)] backdrop-blur-xl flex flex-col">
<aside class="w-60 shrink-0 border-r border-r-[color:var(--ink-soft)] bg-white flex flex-col">
<router-link to="/" class="h-16 flex items-center gap-2.5 px-5 border-b border-b-[color:var(--ink-soft)] group">
<img v-if="site.logo" :src="site.logo" :alt="site.title" class="w-8 h-8 rounded-[10px] object-contain ring-1 ring-[color:var(--ink)]" />
<Logo v-else :size="32" />
<div class="leading-tight min-w-0">
<div class="text-sm font-extrabold truncate tracking-tight text-[color:var(--fg)]">{{ site.title }}</div>
<div class="brand-word text-[15px] truncate max-w-full">{{ site.title }}</div>
<div class="admin-tag text-[11px] font-bold uppercase tracking-widest truncate">Admin</div>
</div>
</router-link>
@@ -64,16 +61,16 @@ watch(() => route.path, () => {
<router-link v-if="!t.children" :to="t.to"
class="admin-link group"
active-class="active">
<span class="absolute left-1 top-1/2 -translate-y-1/2 w-[6px] h-6 rounded-sm opacity-0 transition-opacity"
style="background: var(--m-orange)"></span>
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-full opacity-0 transition-opacity"
style="background: #18181b"></span>
<Icon :name="t.icon" class="w-4 h-4 shrink-0 opacity-70 group-hover:opacity-100 transition-opacity" />
<span class="text-sm">{{ t.label }}</span>
</router-link>
<div v-else>
<button type="button" @click="toggleGroup(t.label)"
class="admin-link group w-full text-left" :class="groupActive(t) && 'active'">
<span class="absolute left-1 top-1/2 -translate-y-1/2 w-[6px] h-6 rounded-sm opacity-0 transition-opacity"
style="background: var(--m-orange)"></span>
<span class="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-full opacity-0 transition-opacity"
style="background: #18181b"></span>
<Icon :name="t.icon" class="w-4 h-4 shrink-0 opacity-70 group-hover:opacity-100 transition-opacity" />
<span class="text-sm">{{ t.label }}</span>
<svg class="w-3 h-3 ml-auto transition-transform" :class="openGroups.has(t.label) && 'rotate-90'"
@@ -90,12 +87,6 @@ watch(() => route.path, () => {
</nav>
<div class="p-3 border-t border-t-[color:var(--ink-soft)] space-y-1">
<button type="button" @click="toggleTheme"
class="w-full flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs text-[color:var(--fg-2)] hover:bg-[var(--hover)] hover:text-[color:var(--fg)] transition-colors">
<svg v-if="isDark" class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg v-else class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
{{ isDark ? '亮色模式' : '暗色模式' }}
</button>
<router-link to="/user"
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs text-[color:var(--fg-2)] hover:bg-[var(--hover)] hover:text-[color:var(--fg)] transition-colors">
<Icon name="spark" class="w-3.5 h-3.5" /> 用户端
@@ -106,25 +97,9 @@ watch(() => route.path, () => {
<!-- ===== Main ===== -->
<div class="flex-1 min-w-0 flex flex-col relative">
<!-- aurora backdrop: drifting gradient orbs + faint grain -->
<div aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="memphis-dots absolute inset-0 opacity-40"></div>
<div class="absolute -top-40 left-1/4 w-[40rem] h-[40rem] rounded-full opacity-[0.45]"
style="background: radial-gradient(circle, rgb(139 92 246 / 0.3), transparent 60%); filter: blur(110px)"></div>
<div class="absolute top-1/2 -right-48 w-[36rem] h-[36rem] rounded-full opacity-[0.4]"
style="background: radial-gradient(circle, rgb(34 211 238 / 0.24), transparent 60%); filter: blur(110px)"></div>
<div class="absolute bottom-0 -left-24 w-[32rem] h-[32rem] rounded-full opacity-[0.35]"
style="background: radial-gradient(circle, rgb(232 121 249 / 0.2), transparent 60%); filter: blur(110px)"></div>
</div>
<header class="relative z-10 h-14 shrink-0 border-b border-b-[color:var(--ink-soft)] bg-[var(--app-bg)]/60 backdrop-blur-xl flex items-center px-8">
<div class="admin-badge text-[10px] uppercase tracking-[0.25em] font-extrabold mr-3 px-2.5 py-1 rounded-lg text-white select-none">Admin</div>
<div class="text-[color:var(--fg-faint)] mr-3">/</div>
<h1 class="text-sm font-semibold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</h1>
</header>
<main :class="['theme-text flex-1 overflow-y-auto overscroll-y-none relative z-10', { 'public-dark': isDark }]">
<div class="px-8 py-7">
<main :class="['theme-text flex-1 overflow-y-auto overscroll-y-none relative z-10 [scrollbar-gutter:stable]']">
<div class="px-8 py-8 max-w-[1500px] mx-auto">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
@@ -138,15 +113,11 @@ watch(() => route.path, () => {
<style scoped>
.admin-tag {
background: linear-gradient(120deg, #7c5cfc, #22d3ee);
background: linear-gradient(120deg, #18181b, #52525b);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.admin-badge {
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 60%, #22d3ee 140%);
box-shadow: 0 4px 14px var(--accent-glow);
}
.admin-link {
position: relative;
display: flex;
@@ -159,14 +130,14 @@ watch(() => route.path, () => {
font-weight: 600;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.admin-link:hover { background: var(--hover); color: var(--fg); border-color: rgb(124 92 252 / 0.3); }
.admin-link:hover { background: rgb(24 24 27 / 0.05); color: var(--fg); }
.admin-link.active {
color: #fff;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
border-color: transparent;
box-shadow: 0 4px 14px var(--accent-glow);
color: #18181b;
background: rgb(24 24 27 / 0.07);
font-weight: 800;
}
.admin-link.active > span:first-child { opacity: 0; }
.admin-link.active > :deep(svg) { opacity: 1; }
.admin-link.active > span:first-child { opacity: 1 !important; }
.admin-sublink {
display: flex;
@@ -178,10 +149,10 @@ watch(() => route.path, () => {
font-weight: 600;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.admin-sublink:hover { background: var(--hover); color: var(--fg); border-color: rgb(124 92 252 / 0.3); }
.admin-sublink.active { color: var(--m-orange); background: var(--hover); border-color: rgb(124 92 252 / 0.35); font-weight: 700; }
.admin-sublink:hover { background: rgb(24 24 27 / 0.05); color: var(--fg); }
.admin-sublink.active { color: #18181b; background: rgb(24 24 27 / 0.07); font-weight: 800; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
.fade-enter-from { opacity: 0; transform: translateY(4px); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.12s ease; }
.fade-enter-from { opacity: 0; }
.fade-leave-to { opacity: 0; }
</style>
+193 -113
View File
@@ -1,13 +1,11 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { auth, isAuthed, isAdmin, openLogin, openRegister, logout } from '../auth'
import { isDark, toggleTheme } from '../theme'
import { site } from '../site'
import Icon from '../components/Icon.vue'
import Logo from '../components/Logo.vue'
import { pointsLabel } from '../credits'
import { draft } from '../playground'
import { announcement, openAnnouncement } from '../announcement'
const route = useRoute()
@@ -29,19 +27,26 @@ const nav = computed(() => {
// Avatar menu — only the surfaces the top nav doesn't already expose
// (画图/图片 live in the bar, so they're intentionally left out here).
const userMenu = [
{ to: '/mylogs', label: '生成日志' },
{ to: '/invite', label: '邀请返利' },
{ to: '/orders', label: '订单' },
{ to: '/settings', label: '设置' },
{ to: '/mylogs', label: '生成日志', icon: 'log' },
{ to: '/invite', label: '邀请返利', icon: 'team' },
{ to: '/orders', label: '订单', icon: 'receipt' },
{ to: '/settings', label: '设置', icon: 'config' },
]
const menuOpen = ref(false) // avatar dropdown
const mobileOpen = ref(false) // hamburger panel
watch(() => route.fullPath, () => { menuOpen.value = false; mobileOpen.value = false })
// 悬浮胶囊顶栏:静止时浮在页面上方(999px 圆角),滚动后展开为通栏。
const scrolled = ref(false)
function onScroll() { scrolled.value = window.scrollY > 4 }
onMounted(() => { onScroll(); window.addEventListener('scroll', onScroll, { passive: true }) })
onUnmounted(() => window.removeEventListener('scroll', onScroll))
// credits — the logged-in user's real server-side balance (auth.user.credits)
const credits = computed(() => Number(auth.user?.credits || 0))
const creditsLabel = computed(() => pointsLabel(credits.value))
const avatarChar = computed(() => Array.from(displayName.value)[0]?.toUpperCase() || '?')
const displayName = computed(() => {
const n = (auth.user?.name || '').trim()
if (n) return n
@@ -49,15 +54,8 @@ const displayName = computed(() => {
return e ? e.split('@')[0] : '我的'
})
// On the 画图 workbench the label tracks the active mode — it flips with the
// 生图/生视频 tab, and on state restore reflects a pending job's kind.
const currentLabel = computed(() => {
if (route.path === '/user') return draft.mode === 'video' ? '生视频' : '生图'
return route.meta?.label || ''
})
// The home page paints its own full-bleed hero; other pages keep the padded
// shell plus a route-label header.
// shell.
const isHome = computed(() => route.path === '/')
// CanvasView's root is a <Teleport to="body"> — keep it outside the
@@ -70,15 +68,16 @@ async function onLogout() {
</script>
<template>
<div class="theme-x min-h-screen bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-orange-400/30">
<!-- ===== Top bar ===== -->
<header class="fixed top-0 inset-x-0 z-40 backdrop-blur-xl top-bar">
<div class="mx-auto max-w-[1400px] px-4 md:px-8 h-[58px] flex items-center gap-3">
<div class="theme-x min-h-screen bg-[var(--app-bg)] text-[color:var(--fg-2)] selection:bg-orange-500/30"
:class="{ 'home-bg': isHome }">
<!-- ===== Top bar:悬浮胶囊,滚动展开为通栏 ===== -->
<header class="top-nav fixed top-0 inset-x-0 z-40" :class="{ scrolled }">
<div class="nav-inner mx-auto flex items-center gap-3">
<!-- brand -->
<router-link to="/" class="flex items-center gap-2.5 shrink-0 transition-transform hover:scale-[1.03]">
<img v-if="site.logo" :src="site.logo" :alt="site.title" class="w-8 h-8 object-contain" />
<Logo v-else :size="30" />
<span class="text-[17px] font-bold tracking-tight text-[color:var(--fg)]">{{ site.title }}</span>
<span class="brand-word text-[19px]">{{ site.title }}</span>
<span class="brand-spark" aria-hidden="true">✦</span>
</router-link>
@@ -92,49 +91,46 @@ async function onLogout() {
<div class="flex-1"></div>
<!-- balance (signed in) -->
<router-link v-if="isAuthed()" to="/settings"
class="hidden sm:block text-xs text-[color:var(--fg-2)] hover:text-[color:var(--fg)] tabular-nums transition-colors">
余额 <span class="text-[color:var(--fg)] font-semibold">{{ creditsLabel }}</span>
</router-link>
<!-- theme -->
<button type="button" @click="toggleTheme" class="round-btn"
:title="isDark ? '切换到亮色' : '切换到暗色'">
<svg v-if="isDark" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg v-else class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
<!-- 公告 -->
<button v-if="isAuthed() && announcement.content.trim()" type="button"
@click="openAnnouncement" class="round-btn" title="公告">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l18-7-4 14-6-3-3 4-1-6z"/></svg>
</button>
<!-- admin shortcut -->
<router-link v-if="isAdmin()" to="/admin/overview" class="round-btn" title="进入管理后台">
<Icon name="shield" class="w-4 h-4" />
</router-link>
<!-- guest actions -->
<template v-if="!isAuthed()">
<button type="button" class="hidden sm:inline-flex top-link" @click="openLogin('/user')">登录</button>
<button type="button" class="solid-btn" @click="openRegister('', '/user')">
免费开始 <span aria-hidden="true">→</span>
</button>
<button type="button" class="login-pill" @click="openLogin('/user')">登录 / 注册</button>
</template>
<!-- signed-in: avatar menu -->
<!-- signed-in: avatar menu(余额 / 后台入口都收进来) -->
<div v-else class="relative">
<button type="button" class="name-btn" @click.stop="menuOpen = !menuOpen" title="我的">
<button type="button" class="name-btn" :class="{ open: menuOpen }" @click.stop="menuOpen = !menuOpen" title="我的">
<span class="avatar">{{ avatarChar }}</span>
<span class="name-txt">{{ displayName }}</span>
<svg class="w-3.5 h-3.5 shrink-0 transition-transform" :class="menuOpen && 'rotate-180'"
<svg class="w-3.5 h-3.5 shrink-0 text-[color:var(--fg-3)] transition-transform" :class="menuOpen && 'rotate-180'"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div v-if="menuOpen" class="menu" @click.stop>
<router-link v-for="m in userMenu" :key="m.to" :to="m.to" class="menu-item">{{ m.label }}</router-link>
<div class="menu-head">
<span class="avatar avatar--lg">{{ avatarChar }}</span>
<div class="min-w-0">
<div class="menu-name">{{ displayName }}</div>
<div v-if="auth.user?.email" class="menu-mail">{{ auth.user.email }}</div>
</div>
</div>
<router-link to="/settings" class="menu-balance">
<span class="mb-label">余额</span>
<span class="mb-val">{{ creditsLabel }}</span>
<span class="mb-go">充值 →</span>
</router-link>
<router-link v-for="m in userMenu" :key="m.to" :to="m.to" class="menu-item">
<Icon :name="m.icon" :size="15" /> {{ m.label }}
</router-link>
<router-link v-if="isAdmin()" to="/admin/overview" class="menu-item">
<Icon name="shield" :size="15" /> 管理后台
</router-link>
<div class="menu-sep"></div>
<button type="button" class="menu-item w-full text-left" @click="onLogout">退出登录</button>
<button type="button" class="menu-item menu-item--out w-full text-left" @click="onLogout">退出登录</button>
</div>
</div>
@@ -145,7 +141,7 @@ async function onLogout() {
</div>
<!-- mobile panel -->
<div v-if="mobileOpen" class="md:hidden border-t border-[color:var(--ink-soft)] px-3 py-3 bg-[var(--menu-bg)]">
<div v-if="mobileOpen" class="mobile-panel md:hidden px-3 py-3">
<router-link v-for="n in nav" :key="n.to" :to="n.to"
:exact-active-class="n.to === '/' ? 'm-on' : ''"
:active-class="n.to === '/' ? '' : 'm-on'"
@@ -153,6 +149,7 @@ async function onLogout() {
<template v-if="isAuthed()">
<div class="menu-sep"></div>
<router-link v-for="m in userMenu" :key="m.to" :to="m.to" active-class="m-on" class="m-link">{{ m.label }}</router-link>
<router-link v-if="isAdmin()" to="/admin/overview" class="m-link">管理后台</router-link>
</template>
<template v-else>
<div class="menu-sep"></div>
@@ -167,26 +164,9 @@ async function onLogout() {
<!-- ===== Main column ===== -->
<div class="relative">
<!-- aurora backdrop: drifting gradient orbs + faint grain -->
<div aria-hidden="true" class="pointer-events-none absolute inset-0 overflow-hidden">
<div class="memphis-dots absolute inset-0 opacity-50"></div>
<div class="absolute -top-40 left-1/4 w-[42rem] h-[42rem] rounded-full opacity-[0.5]"
style="background: radial-gradient(circle, rgb(139 92 246 / 0.35), transparent 60%); filter: blur(100px)"></div>
<div class="absolute top-1/3 -right-48 w-[38rem] h-[38rem] rounded-full opacity-[0.45]"
style="background: radial-gradient(circle, rgb(34 211 238 / 0.3), transparent 60%); filter: blur(100px)"></div>
<div class="absolute bottom-0 -left-24 w-[34rem] h-[34rem] rounded-full opacity-[0.4]"
style="background: radial-gradient(circle, rgb(232 121 249 / 0.28), transparent 60%); filter: blur(100px)"></div>
<div class="anime-stars"></div>
</div>
<!-- Route label header — the home page brings its own hero instead. -->
<header v-if="!isHome" class="relative z-10 mx-auto max-w-[1400px] px-4 md:px-8 pt-[86px] pb-4">
<span class="text-[22px] font-bold tracking-tight text-[color:var(--fg)]">{{ currentLabel }}</span>
</header>
<main :class="['relative z-10 mx-auto max-w-[1400px] pb-24',
isHome ? 'pt-[58px] px-0' : 'px-4 md:px-8 pt-2',
{ 'public-dark': isDark }]">
isHome ? 'pt-0 px-0' : 'px-4 md:px-8 pt-[96px]']">
<router-view v-slot="{ Component, route: viewRoute }">
<component :is="Component" v-if="viewRoute.path === '/canvas'" />
<transition v-else name="fade" mode="out-in">
@@ -199,30 +179,67 @@ async function onLogout() {
</template>
<style scoped>
.top-bar {
background: color-mix(in srgb, var(--app-bg) 62%, transparent);
/* 悬浮胶囊导航:不压满宽黑线,毛玻璃胶囊浮在页面上方;滚动后展开为通栏 */
.top-nav {
padding-top: 10px;
background: transparent;
pointer-events: none;
transition: padding-top 0.22s ease;
}
.nav-inner {
pointer-events: auto;
height: 64px;
width: min(1200px, calc(100% - 40px));
padding: 0 14px 0 20px;
border-radius: 999px;
background: var(--menu-bg);
-webkit-backdrop-filter: blur(20px) saturate(1.7);
backdrop-filter: blur(20px) saturate(1.7);
border: 1px solid var(--ink-soft);
box-shadow: 0 8px 30px -14px rgb(15 23 42 / 0.18);
transition: width 0.25s ease, border-radius 0.25s ease, border-color 0.25s ease,
box-shadow 0.25s ease, background 0.25s ease;
}
.top-nav.scrolled { padding-top: 0; }
.top-nav.scrolled .nav-inner {
width: 100%;
border-radius: 0;
border-color: transparent;
border-bottom: 1px solid var(--ink-soft);
transition: border-color 0.2s ease;
box-shadow: 0 10px 30px -12px rgb(15 23 42 / 0.12);
}
html.dark .nav-inner { box-shadow: 0 8px 30px -14px rgb(0 0 0 / 0.5); }
.mobile-panel {
pointer-events: auto;
width: min(1200px, calc(100% - 32px));
margin: 6px auto 0;
border-radius: 1rem;
background: var(--menu-bg);
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
border: 1px solid var(--ink-soft);
box-shadow: var(--glass-shadow-lg);
}
.top-link {
padding: 0.45rem 0.8rem;
border-radius: 0.6rem;
font-size: 13.5px;
padding: 0.55rem 1.1rem;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
color: var(--fg-2);
background: transparent;
border: 1px solid transparent;
border: none;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
transition: color 0.2s ease, background 0.2s ease, transform 0.15s ease;
}
.top-link:hover { color: var(--fg); background: var(--hover); border-color: rgb(124 92 252 / 0.3); }
.top-link:active { transform: scale(0.95); }
.top-link:hover { color: var(--fg); background: var(--hover); }
.home-bg .top-link.on { color: #18181b; background: rgba(24, 24, 27, 0.07); }
.top-link.on {
color: #fff;
color: var(--m-orange);
font-weight: 700;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
border-color: transparent;
box-shadow: 0 4px 14px var(--accent-glow);
background: rgb(24 24 27 / 0.12);
}
.brand-spark {
@@ -249,11 +266,11 @@ async function onLogout() {
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.m-link:hover { color: var(--fg); background: var(--hover); border-color: rgb(124 92 252 / 0.3); }
.m-link:hover { color: var(--fg); background: var(--hover); border-color: rgb(24 24 27 / 0.3); }
.m-link.m-on {
color: #fff;
font-weight: 700;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
background: linear-gradient(120deg, #18181b, #27272a);
border-color: transparent;
box-shadow: 0 4px 14px var(--accent-glow);
}
@@ -271,7 +288,21 @@ async function onLogout() {
border: 1px solid var(--ink-soft);
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
}
.round-btn:hover { color: var(--fg); background: var(--hover); border-color: rgb(124 92 252 / 0.4); box-shadow: 0 4px 14px rgb(124 92 252 / 0.16); transform: translateY(-1px); }
.round-btn:hover { color: var(--fg); background: var(--hover); border-color: rgb(24 24 27 / 0.4); box-shadow: 0 4px 14px rgb(24 24 27 / 0.16); transform: translateY(-1px); }
/* 首页整页淡紫渐变底(铺满整个页面高度) */
.home-bg {
background: #fafaf8 !important;
}
/* 游客登录入口:vivid 同款渐变胶囊 */
.login-pill {
display: inline-flex; align-items: center;
padding: 8px 18px; border: none; border-radius: 999px;
background: #18181b;
color: #fff; font-family: inherit; font-size: 13px; font-weight: 700;
cursor: pointer; box-shadow: 0 8px 20px -8px rgba(9, 9, 11, 0.5);
transition: transform 0.15s ease, filter 0.2s ease;
}
.login-pill:hover { transform: translateY(-1px); filter: brightness(1.08); }
.round-btn:active { transform: scale(0.95) translateY(1px); box-shadow: none; }
/* mobile-only: .round-btn's display:grid outranks Tailwind's md:hidden, so
@@ -287,7 +318,7 @@ async function onLogout() {
font-size: 13.5px;
font-weight: 800;
color: #fff;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee 140%);
background: linear-gradient(120deg, #18181b, #27272a 55%, #52525b 140%);
border: 1px solid rgb(255 255 255 / 0.16);
cursor: pointer;
box-shadow: 0 6px 18px var(--accent-glow);
@@ -299,62 +330,111 @@ async function onLogout() {
.name-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
max-width: 10rem;
height: 2.125rem;
padding: 0 0.7rem 0 0.85rem;
border-radius: 0.6rem;
border: 1px solid var(--ink-soft);
gap: 0.5rem;
max-width: 12rem;
height: 2.5rem;
padding: 0 0.7rem 0 0.3rem;
border-radius: 999px;
border: 1px solid rgb(24 24 27 / 0.1);
cursor: pointer;
font-size: 13px;
font-weight: 700;
color: var(--fg);
background: var(--surface);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
background: var(--surface-solid);
transition: background 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.name-btn:hover {
background: var(--hover);
border-color: rgb(124 92 252 / 0.4);
box-shadow: 0 4px 14px rgb(124 92 252 / 0.16);
transform: translateY(-1px);
.name-btn:hover, .name-btn.open {
border-color: rgb(24 24 27 / 0.28);
box-shadow: 0 4px 14px rgb(24 24 27 / 0.1);
}
.name-txt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.avatar {
flex-shrink: 0;
width: 1.9rem;
height: 1.9rem;
border-radius: 50%;
display: grid;
place-items: center;
background: #18181b;
color: #fff;
font-size: 13px;
font-weight: 800;
}
.avatar--lg { width: 2.6rem; height: 2.6rem; font-size: 16px; }
.menu {
position: absolute;
right: 0;
top: 2.75rem;
width: 11.5rem;
padding: 0.4rem;
border-radius: 0.85rem;
background: var(--menu-bg);
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow-lg);
top: 3.1rem;
width: 16rem;
padding: 0.5rem;
border-radius: 1rem;
background: var(--surface-solid);
border: 1px solid rgb(24 24 27 / 0.08);
box-shadow: 0 2px 6px rgb(24 24 27 / 0.05), 0 24px 48px -12px rgb(24 24 27 / 0.22);
z-index: 50;
}
.menu-head {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.5rem 0.5rem 0.65rem;
}
.menu-name {
font-size: 14px;
font-weight: 800;
color: var(--fg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-mail {
margin-top: 1px;
font-size: 12px;
color: var(--fg-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-balance {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin: 0 0 0.35rem;
padding: 0.7rem 0.8rem;
border-radius: 0.75rem;
background: #f4f4f5;
text-decoration: none;
transition: background 0.15s ease;
}
.menu-balance:hover { background: #ececee; }
.mb-label { font-size: 12px; font-weight: 600; color: var(--fg-3); }
.mb-val { font-size: 20px; font-weight: 800; color: var(--fg); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.mb-go { margin-left: auto; font-size: 12px; font-weight: 700; color: var(--fg-2); }
.menu-item {
display: block;
padding: 0.5rem 0.65rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.55rem 0.7rem;
border-radius: 0.6rem;
font-size: 13px;
font-weight: 600;
color: var(--fg-2);
background: transparent;
border: none;
cursor: pointer;
text-decoration: none;
}
.menu-item:hover { background: var(--hover); color: var(--fg); }
.menu-item:hover { background: rgb(24 24 27 / 0.06); color: var(--fg); }
.menu-item--out { color: #dc2626; }
.menu-item--out:hover { background: rgb(220 38 38 / 0.08); color: #dc2626; }
.menu-sep { height: 1px; background: var(--hairline); margin: 0.35rem 0.25rem; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.fade-enter-from { opacity: 0; transform: translateY(8px); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from { opacity: 0; }
.fade-leave-to { opacity: 0; }
</style>
+186 -383
View File
@@ -9,34 +9,34 @@ video {
:root {
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
--tw-ring-color: rgb(124 92 252 / 0.25);
--tw-ring-color: rgb(24 24 27 / 0.25);
/* ===== Theme palette — LIGHT (default): frosted glass / aurora =====
Drives the app chrome (rails/header) and the public marketing pages.
The `.dark` class on <html> flips these to the dark values below.
Text over generated images keeps a literal text-white (handled in markup). */
--app-bg: #eef0f8; /* cool porcelain */
--app-bg: #fafaf8; /* warm paper */
--fg: #181524; /* primary heading/text */
--fg-2: rgb(24 21 36 / 0.66); /* body / secondary */
--fg-3: rgb(24 21 36 / 0.48); /* muted */
--fg-faint: rgb(24 21 36 / 0.36); /* labels / hints */
--surface: rgb(255 255 255 / 0.66); /* frosted card on page bg */
--surface: rgb(255 255 255 / 0.9); /* frosted card on page bg */
--surface-solid: #ffffff; /* opaque version where blur is unwanted */
--surface-2: rgb(24 21 36 / 0.045); /* subtle inset panel (counter cells) */
--hairline: rgb(24 21 36 / 0.1); /* faint borders / separators */
--hover: rgb(124 92 252 / 0.1); /* hover wash — violet tint */
--hover: rgb(24 24 27 / 0.06); /* hover wash — violet tint */
/* ink = the hairline edge color; shadow-ink = the soft shadow tint.
Kept under their legacy names so existing markup keeps working — only the
values changed from memphis-solid to glass-subtle. */
--ink: rgb(24 21 36 / 0.14);
--ink-soft: rgb(24 21 36 / 0.09);
--shadow-ink: rgb(88 80 200 / 0.14);
--shadow-ink: rgb(24 24 27 / 0.12);
/* aurora accent crayons — primary violet + soft pastel companions */
--m-orange: #7c5cfc;
--m-orange-2: #22d3ee;
--m-yellow: #ece7fe;
--m-pink: #fad9f0;
--m-blue: #d8eefb;
--m-orange: #18181b;
--m-orange-2: #52525b;
--m-yellow: #f4f4f5;
--m-pink: #f4f4f5;
--m-blue: #f4f4f5;
--m-mint: #d9f4e8;
/* solid CTA button that sits on the page bg — inverts per theme */
--btn-solid-bg: #181524;
@@ -45,11 +45,13 @@ video {
--menu-bg: rgb(255 255 255 / 0.82); /* dropdown / popover panel */
/* shared glass treatments */
--glass-border: rgb(255 255 255 / 0.55);
--glass-shadow: 0 8px 28px rgb(80 74 190 / 0.1), 0 2px 8px rgb(24 21 36 / 0.05);
--glass-shadow-lg: 0 16px 44px rgb(80 74 190 / 0.16), 0 4px 12px rgb(24 21 36 / 0.06);
--accent-glow: rgb(124 92 252 / 0.35);
--glass-shadow: 0 8px 28px rgb(24 24 27 / 0.07), 0 2px 8px rgb(24 21 36 / 0.04);
--glass-shadow-lg: 0 16px 44px rgb(24 24 27 / 0.12), 0 4px 12px rgb(24 21 36 / 0.05);
--accent-glow: rgb(24 24 27 / 0.25);
}
html { scrollbar-gutter: stable; }
html.dark {
--app-bg: #08070f;
--fg: rgb(255 255 255 / 0.93);
@@ -60,24 +62,24 @@ html.dark {
--surface-solid: #14131f;
--surface-2: rgb(255 255 255 / 0.05);
--hairline: rgb(255 255 255 / 0.1);
--hover: rgb(139 92 246 / 0.16);
--hover: rgb(24 24 27 / 0.16);
--ink: rgb(255 255 255 / 0.14);
--ink-soft: rgb(255 255 255 / 0.09);
--shadow-ink: rgb(0 0 0 / 0.4);
--m-orange: #8b5cf6;
--m-orange-2: #22d3ee;
--m-yellow: #a78bfa;
--m-orange: #27272a;
--m-orange-2: #52525b;
--m-yellow: #71717a;
--m-pink: #f0a6d8;
--m-blue: #6cd0f2;
--m-mint: #5fd9ac;
--btn-solid-bg: #8b5cf6;
--btn-solid-bg-h: #7c3aed;
--btn-solid-bg: #27272a;
--btn-solid-bg-h: #18181b;
--btn-solid-fg: #fff;
--menu-bg: rgb(24 23 36 / 0.86);
--glass-border: rgb(255 255 255 / 0.12);
--glass-shadow: 0 8px 28px rgb(0 0 0 / 0.35), 0 2px 8px rgb(0 0 0 / 0.2);
--glass-shadow-lg: 0 18px 50px rgb(0 0 0 / 0.5), 0 4px 14px rgb(0 0 0 / 0.3);
--accent-glow: rgb(139 92 246 / 0.45);
--accent-glow: rgb(24 24 27 / 0.45);
}
/* Smooth the flip so toggling doesn't hard-cut. */
@@ -87,16 +89,16 @@ body, .theme-x { transition: background-color 0.25s ease, color 0.25s ease; }
.cta-band {
position: relative;
background:
radial-gradient(120% 160% at 0% 0%, rgb(124 92 252 / 0.2), transparent 55%),
radial-gradient(120% 160% at 100% 100%, rgb(34 211 238 / 0.16), transparent 55%),
radial-gradient(120% 160% at 0% 0%, rgb(24 24 27 / 0.2), transparent 55%),
radial-gradient(120% 160% at 100% 100%, rgb(24 24 27 / 0.16), transparent 55%),
var(--surface);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow-lg);
}
html.dark .cta-band {
background:
radial-gradient(120% 160% at 0% 0%, rgb(139 92 246 / 0.22), transparent 55%),
radial-gradient(120% 160% at 100% 100%, rgb(34 211 238 / 0.14), transparent 55%),
radial-gradient(120% 160% at 0% 0%, rgb(24 24 27 / 0.22), transparent 55%),
radial-gradient(120% 160% at 100% 100%, rgb(24 24 27 / 0.14), transparent 55%),
rgb(255 255 255 / 0.04);
}
@@ -110,7 +112,7 @@ html:not(.dark) .theme-text :is(.text-white, [class*="text-white/9"], [class*="t
html:not(.dark) .theme-text :is([class*="text-white/7"], [class*="text-white/6"]) { color: rgb(24 21 36 / 0.7); }
html:not(.dark) .theme-text :is([class*="text-white/5"], [class*="text-white/4"]) { color: rgb(24 21 36 / 0.52); }
html:not(.dark) .theme-text :is([class*="text-white/3"], [class*="text-white/2"], [class*="text-white/1"]) { color: rgb(24 21 36 / 0.4); }
html:not(.dark) .theme-text [class*="bg-white/"] { background-color: var(--surface-2); }
html:not(.dark) .theme-text :is([class^="bg-white/"], [class*=" bg-white/"]) { background-color: var(--surface-2); }
html:not(.dark) .theme-text [class*="border-white/"] { border-color: var(--hairline); }
html:not(.dark) .theme-text [class*="ring-white/"] { --tw-ring-color: var(--hairline); }
/* White placeholders (placeholder:text-white/30 etc.) — the rescue above only
@@ -124,8 +126,8 @@ html:not(.dark) .theme-text :is([class*="bg-slate-900"], [class*="bg-slate-800"]
shell and washes out on white. In LIGHT mode, darken to the -600 tone. The
matcher also catches the /80 opacity variants. Badges keep their pale tinted
bg (bg-*-500/10) which reads fine on white. */
html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(79 70 229); }
html:not(.dark) .theme-text :is([class*="text-orange-300"], [class*="text-orange-200"]) { color: rgb(124 58 237); }
html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(24 24 27); }
html:not(.dark) .theme-text :is([class*="text-orange-300"], [class*="text-orange-200"]) { color: rgb(24 24 27); }
html:not(.dark) .theme-text :is([class*="text-rose-300"], [class*="text-rose-200"]) { color: rgb(225 29 72); }
html:not(.dark) .theme-text :is([class*="text-emerald-300"], [class*="text-emerald-200"]) { color: rgb(5 150 105); }
html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(2 132 199); }
@@ -137,14 +139,21 @@ html:not(.dark) .theme-text :is([class*="text-teal-300"], [class*="text-teal-200
Recolor them centrally in light mode — the extra .theme-text class outranks
the scoped `.x[data-v-*]` rules. Dark mode keeps the scoped look (gated on
:not(.dark)). */
html:not(.dark) .theme-text :is(.fp, .pg, .act, .filter-pill, .kind-btn, .preset-btn, .opt, .chip-x, .ro-chip) {
html:not(.dark) .theme-text :is(.fp, .pg, .filter-pill, .kind-btn, .preset-btn, .opt, .chip-x, .ro-chip) {
color: var(--fg-2); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--hairline);
}
html:not(.dark) .theme-text :is(.fp, .pg, .act, .filter-pill, .kind-btn, .preset-btn, .opt, .chip-x):hover {
html:not(.dark) .theme-text :is(.fp, .pg, .filter-pill, .kind-btn, .preset-btn, .opt, .chip-x):hover {
color: var(--fg); background: var(--hover);
}
/* 行内操作图标按钮:白底灰边,悬停浅灰加深描边 */
html:not(.dark) .theme-text .act {
color: var(--fg-3); background: var(--surface-solid); box-shadow: inset 0 0 0 1px var(--hairline);
}
html:not(.dark) .theme-text .act:hover {
color: var(--fg); background: var(--surface-2); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.14);
}
html:not(.dark) .theme-text :is(.fp-on, .pg-on, .seg-on, .opt-on) {
background: linear-gradient(120deg, #7c5cfc, #5f6cf8); color: #fff;
background: linear-gradient(120deg, #18181b, #27272a); color: #fff;
box-shadow: 0 4px 16px var(--accent-glow);
}
/* Tag chips (TagInput) — near-white orange text is unreadable on white; keep
@@ -157,7 +166,7 @@ html:not(.dark) .theme-text .chip .chip-x:hover { color: #fff; background: rgb(2
neutral `.filter-pill`/`.kind-btn` rescue paints selected == unselected and
the highlight vanishes on a white page. Equal-or-higher specificity, later. */
html:not(.dark) .theme-text :is(.filter-pill, .kind-btn).on {
background: linear-gradient(120deg, #7c5cfc, #5f6cf8); color: #fff;
background: linear-gradient(120deg, #18181b, #27272a); color: #fff;
box-shadow: 0 4px 16px var(--accent-glow);
}
/* Selected COLORED filter pills — their scoped (dark) selected colors get
@@ -167,7 +176,7 @@ html:not(.dark) .theme-text :is(.filter-pill, .kind-btn).on {
html:not(.dark) .theme-text .fp-emerald { background: rgb(16 185 129 / 0.14); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.45); }
html:not(.dark) .theme-text .fp-rose { background: rgb(244 63 94 / 0.12); color: rgb(190 18 60); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.45); }
html:not(.dark) .theme-text .fp-amber { background: rgb(245 158 11 / 0.16); color: rgb(180 83 9); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.5); }
html:not(.dark) .theme-text .fp-orange { background: rgb(139 92 246 / 0.14); color: rgb(109 40 217); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.45); }
html:not(.dark) .theme-text .fp-orange { background: rgb(24 24 27 / 0.14); color: rgb(24 24 27); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.45); }
html:not(.dark) .theme-text .fp-pink { background: rgb(244 114 182 / 0.14); color: rgb(190 24 93); box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.45); }
html:not(.dark) .theme-text .fp-slate { background: rgb(100 116 139 / 0.14); color: rgb(51 65 85); box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.45); }
html:not(.dark) .theme-text .fp-sky { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.45); }
@@ -193,7 +202,7 @@ html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color
.chk { border-color: var(--ink); }
.chk:hover:not(:checked) { border-color: var(--m-orange); }
.chk:checked {
background: linear-gradient(135deg, #7c5cfc, #22d3ee);
background: linear-gradient(135deg, #18181b, #52525b);
border-color: transparent;
box-shadow: 0 2px 10px var(--accent-glow);
}
@@ -206,7 +215,7 @@ html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color
transform: rotate(45deg);
margin-top: -0.12rem;
}
.chk:focus-visible { outline: 2px solid rgb(124 92 252 / 0.5); outline-offset: 1px; }
.chk:focus-visible { outline: 2px solid rgb(24 24 27 / 0.5); outline-offset: 1px; }
/* Account identity tags (AccountsView: 子号 / 母号 / 普号 / 限额). The colors live
here rather than as inline dark-palette utilities, which were invisible on the
light theme: light values first, html.dark override after. */
@@ -222,11 +231,11 @@ html:not(.dark) .theme-text .fp-teal { background: rgb(20 184 166 / 0.14); color
white-space: nowrap;
}
.acct-tag-sub { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.4); }
.acct-tag-master { background: rgb(139 92 246 / 0.14); color: rgb(109 40 217); box-shadow: inset 0 0 0 1px rgb(139 92 246 / 0.4); }
.acct-tag-master { background: rgb(24 24 27 / 0.14); color: rgb(24 24 27); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.4); }
.acct-tag-free { background: rgb(100 116 139 / 0.14); color: rgb(51 65 85); box-shadow: inset 0 0 0 1px rgb(100 116 139 / 0.4); }
.acct-tag-limit { background: rgb(245 158 11 / 0.16); color: rgb(180 83 9); box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.4); }
html.dark .acct-tag-sub { background: rgb(14 165 233 / 0.18); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.35); }
html.dark .acct-tag-master { background: rgb(139 92 246 / 0.22); color: rgb(196 181 253); box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.35); }
html.dark .acct-tag-master { background: rgb(24 24 27 / 0.22); color: rgb(161 161 170); box-shadow: inset 0 0 0 1px rgb(167 139 250 / 0.35); }
html.dark .acct-tag-free { background: rgb(148 163 184 / 0.22); color: rgb(226 232 240); box-shadow: inset 0 0 0 1px rgb(148 163 184 / 0.35); }
html.dark .acct-tag-limit { background: rgb(245 158 11 / 0.18); color: rgb(252 211 77); box-shadow: inset 0 0 0 1px rgb(251 191 36 / 0.35); }
/* Capability chips (ModelsView): all variants need bg + ring + color reset in light mode.
@@ -262,8 +271,8 @@ html:not(.dark) .theme-text .lbl { color: var(--fg); }
html:not(.dark) .theme-text :is(.hint, .flbl) { color: var(--fg-3); }
/* Delete icon button: a clean neutral button with a red glyph (not a heavy pink
box), filling solid red on hover. */
html:not(.dark) .theme-text .act.danger { color: rgb(225 29 72); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--hairline); }
html:not(.dark) .theme-text .act.danger:hover { color: #fff; background: rgb(244 63 94); box-shadow: inset 0 0 0 1px rgb(225 29 72); }
html:not(.dark) .theme-text .act.danger { color: rgb(225 29 72); background: var(--surface-solid); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.25); }
html:not(.dark) .theme-text .act.danger:hover { color: #dc2626; background: rgb(244 63 94 / 0.08); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); }
/* Soft "danger" toolbar buttons (删除选中 / 删除异常账号 …): a muted red, not a
heavy pink fill; solid red on hover. Outranks the per-view scoped pink. */
html:not(.dark) .theme-text .btn-soft.danger { color: rgb(190 18 60); background: rgb(244 63 94 / 0.08); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.22); }
@@ -283,7 +292,7 @@ html:not(.dark) .media-card :is([class*="border-white/"], [class*="ring-white/"]
html:not(.dark) .media-card :is([class*="text-emerald-3"], [class*="text-emerald-2"]) { color: rgb(110 231 183) !important; }
html:not(.dark) .media-card :is([class*="text-rose-3"], [class*="text-rose-2"]) { color: rgb(251 207 232) !important; }
html:not(.dark) .media-card :is([class*="text-sky-3"], [class*="text-sky-2"]) { color: rgb(147 197 253) !important; }
html:not(.dark) .media-card :is([class*="text-orange-3"], [class*="text-orange-2"]) { color: rgb(196 181 253) !important; }
html:not(.dark) .media-card :is([class*="text-orange-3"], [class*="text-orange-2"]) { color: rgb(161 161 170) !important; }
html:not(.dark) .media-card :is([class*="text-sky-3"], [class*="text-sky-2"]) { color: rgb(125 211 252) !important; }
html:not(.dark) .media-card :is([class*="text-amber-3"], [class*="text-amber-2"]) { color: rgb(252 211 77) !important; }
@@ -311,7 +320,7 @@ body {
/* Aurora ambient gradient + grain (used by PublicLayout via .ambient-bg) */
}
::selection { background: rgb(139 92 246 / 0.32); color: inherit; }
::selection { background: rgb(24 24 27 / 0.32); color: inherit; }
html:not(.dark) ::selection { color: #181524; }
/* Tailwind v4 / preflight leaves <button> on the default arrow cursor, so
@@ -326,6 +335,8 @@ button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: poi
columns, let the card scroll horizontally. overflow-y is left to the card's
own utility so vertical clipping / rounded corners are preserved. */
.card:has(> table) { overflow-x: auto; }
/* 后台卡片统一纯白——90% 半透明白叠在米色页底上会发灰 */
.theme-text .card { background: var(--surface-solid); }
/* ===== Admin table polish =====
Every data table lives in a `.card` and views hand-roll their own
@@ -335,329 +346,124 @@ button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: poi
over-media tables are untouched; specificity outranks the per-view
Tailwind hover utilities and the light-mode white rescue. */
.theme-text .card table thead {
background: linear-gradient(180deg, rgb(124 92 252 / 0.07), rgb(124 92 252 / 0.025));
background: transparent;
border-bottom: 1px solid rgb(24 24 27 / 0.07);
}
html.dark .theme-text .card table thead {
background: linear-gradient(180deg, rgb(139 92 246 / 0.13), rgb(139 92 246 / 0.05));
}
.theme-text .card table tbody tr { transition: background-color 0.15s ease; }
.theme-text .card table tbody tr:hover { background-color: rgb(124 92 252 / 0.08); }
html.dark .theme-text .card table tbody tr:hover { background-color: rgb(139 92 246 / 0.11); }
/* Checkbox column + first data column get a hair more air against the
card edge so dense tables don't feel cramped. */
.theme-text .card table tbody tr:hover :is(td, th):first-child {
box-shadow: inset 2px 0 0 rgb(124 92 252 / 0.55);
/* 表头统一:各页曾分别写 10px/11px、tracking 0.2em/0.04em——收拢到同一规格。 */
.theme-text .card table thead :is(th, td) {
font-size: 13px;
font-weight: 650;
letter-spacing: 0.01em;
color: var(--fg-3);
padding-top: 16px;
padding-bottom: 16px;
line-height: 1.4;
white-space: nowrap;
}
.theme-text .card table tbody tr { transition: background-color 0.12s ease; }
.theme-text .card table tbody tr:hover { background-color: #f7f7f8; }
.theme-text .card table tbody tr:last-child { border-bottom: 0; }
/* 多选表格的选中行高亮(行加 .row-on) */
.theme-text .card table tbody tr.row-on { background-color: rgb(24 24 27 / 0.035); }
.theme-text .card table tbody tr.row-on:hover { background-color: #f0f0f2; }
/* 表体单元格基线:统一 13px 字号 + 次要色,数字/名称列各自覆盖 */
.theme-text .card table tbody td { color: var(--fg-2); font-size: 13px; }
.theme-text .card table tbody tr.row-on td:first-child { box-shadow: inset 2px 0 0 #18181b; }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-thumb { background: rgb(24 21 36 / 0.2); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background: rgb(24 21 36 / 0.38); background-clip: content-box; }
::-webkit-scrollbar-track { background: transparent; }
html.dark ::-webkit-scrollbar-thumb { background: rgb(255 255 255 / 0.18); background-clip: content-box; }
html.dark ::-webkit-scrollbar-thumb:hover { background: rgb(255 255 255 / 0.32); background-clip: content-box; }
/* ===== Ambient background — aurora wash used by the public shell ===== */
.ambient-bg {
background-color: var(--app-bg);
background-image:
radial-gradient(60rem 40rem at 12% -10%, rgb(124 92 252 / 0.14), transparent 60%),
radial-gradient(52rem 36rem at 92% 8%, rgb(34 211 238 / 0.11), transparent 60%),
radial-gradient(46rem 32rem at 8% 96%, rgb(240 171 252 / 0.1), transparent 60%);
}
html.dark .ambient-bg {
background-image:
radial-gradient(60rem 40rem at 12% -10%, rgb(139 92 246 / 0.2), transparent 60%),
radial-gradient(52rem 36rem at 92% 8%, rgb(34 211 238 / 0.13), transparent 60%),
radial-gradient(46rem 32rem at 8% 96%, rgb(232 121 249 / 0.1), transparent 60%);
}
/* ===== Glass / aurora design kit =====
Frosted surfaces, hairline edges, soft layered shadows and violet→cyan
gradient accents. Same class names as the previous memphis kit so every
view picks the new look up without markup changes. */
/* Gradient headline text — violet → cyan swipe. */
.anime-grad-text {
background: linear-gradient(100deg, #7c5cfc 0%, #a855f7 45%, #22d3ee 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
padding: 0 0.05em;
}
html.dark .anime-grad-text {
background: linear-gradient(100deg, #a78bfa 0%, #e879f9 45%, #67e8f9 100%);
-webkit-background-clip: text;
background-clip: text;
}
/* Section eyebrow — tiny uppercase kicker with a leading star. */
.anime-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.anime-eyebrow::before { content: '✦'; font-size: 0.85em; }
/* Sticker label — glass chip with a soft glow. */
.sticker {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.34rem 0.85rem;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
color: var(--fg);
background: var(--surface);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
/* Chunky CTA — violet→cyan gradient with a glow. */
.bubble-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.4rem;
border-radius: 0.8rem;
font-size: 14px;
font-weight: 800;
color: #fff;
border: 1px solid rgb(255 255 255 / 0.18);
cursor: pointer;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee);
box-shadow: 0 8px 24px var(--accent-glow);
transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease;
}
.bubble-btn:hover { transform: translateY(-2px); box-shadow: 0 12px 32px var(--accent-glow); filter: brightness(1.06); }
.bubble-btn:active { transform: scale(0.97) translateY(1px); box-shadow: 0 4px 12px var(--accent-glow); }
/* Glass card — frosted surface, hairline edge, soft shadow. */
.anime-card {
position: relative;
border-radius: 1.1rem;
background: var(--surface);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.anime-card:hover {
transform: translateY(-3px);
box-shadow: var(--glass-shadow-lg);
}
/* Selected pill — gradient fill with a glow. */
.chip-on {
color: #fff !important;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8) !important;
border: 1px solid transparent !important;
box-shadow: 0 4px 16px var(--accent-glow) !important;
}
/* Twinkling star field — drop into any relatively-positioned decorative layer.
Uses layered radial-gradients so it's a single element, no extra DOM. */
.anime-stars {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
radial-gradient(1.5px 1.5px at 12% 22%, rgb(139 92 246 / 0.7), transparent),
radial-gradient(1.5px 1.5px at 78% 12%, rgb(34 211 238 / 0.7), transparent),
radial-gradient(1px 1px at 42% 62%, rgb(139 92 246 / 0.6), transparent),
radial-gradient(1.5px 1.5px at 88% 72%, rgb(232 121 249 / 0.6), transparent),
radial-gradient(1px 1px at 24% 84%, rgb(34 211 238 / 0.55), transparent),
radial-gradient(1px 1px at 62% 34%, rgb(167 139 250 / 0.55), transparent);
animation: twinkle 4.5s ease-in-out infinite;
}
@keyframes twinkle { 0%, 100% { opacity: 0.85; } 50% { opacity: 0.4; } }
@media (prefers-reduced-motion: reduce) { .anime-stars { animation: none; } }
.ambient-grain::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22180%22 height=%22180%22><filter id=%22n%22><feTurbulence baseFrequency=%220.9%22 numOctaves=%222%22 seed=%225%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.4%22/></svg>');
opacity: 0.035;
mix-blend-mode: multiply;
z-index: 1;
}
/* Float a soft mesh "orb" anywhere */
.mesh-orb {
position: absolute;
border-radius: 9999px;
filter: blur(80px);
pointer-events: none;
z-index: 0;
}
/* ===== Glass utilities ===== */
/* Dot texture layer — kept faint so it reads as grain, not polka dots. */
.memphis-dots {
background-image: radial-gradient(circle, rgb(24 21 36 / 0.07) 1.1px, transparent 1.1px);
background-size: 26px 26px;
}
html.dark .memphis-dots {
background-image: radial-gradient(circle, rgb(255 255 255 / 0.05) 1.1px, transparent 1.1px);
}
/* Frosted surface helpers for one-off markup. */
.ink-card {
background: var(--surface);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow-lg);
}
.ink-card-sm {
background: var(--surface);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
/* Lift on hover — the signature card interaction. */
.chunky-hover { transition: transform 0.18s ease, box-shadow 0.18s ease; }
.chunky-hover:hover { transform: translateY(-3px); box-shadow: var(--glass-shadow-lg); }
/* Squishy press for buttons. */
.memphis-press { transition: transform 0.15s ease; }
.memphis-press:active { transform: scale(0.97) translateY(1px); }
/* Empty-state badge — frosted tile with a gradient star. */
.empty-badge {
width: 4.5rem;
height: 4.5rem;
display: grid;
place-items: center;
font-size: 1.8rem;
font-weight: 900;
color: #fff;
background: linear-gradient(135deg, #7c5cfc, #22d3ee);
border: 1px solid rgb(255 255 255 / 0.2);
border-radius: 1.2rem;
box-shadow: 0 12px 32px var(--accent-glow);
animation: memphis-float 6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) { .empty-badge { animation: none; } }
@keyframes memphis-wiggle { 0%, 100% { transform: rotate(-3deg); } 50% { transform: rotate(3deg); } }
@keyframes memphis-float { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-10px) rotate(1.5deg); } }
@keyframes memphis-bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
.animate-memphis-wiggle { animation: memphis-wiggle 2s ease-in-out infinite; }
.animate-memphis-float { animation: memphis-float 6s ease-in-out infinite; }
.animate-memphis-bounce { animation: memphis-bounce 2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.animate-memphis-wiggle, .animate-memphis-float, .animate-memphis-bounce { animation: none; }
}
/* ===== Dark public-shell theme overrides =====
Apply by adding `.public-dark` to a wrapper element. Views can keep their
existing Tailwind classes (text-slate-*, .card, .field) — these rules
remap them to the dark palette. */
.public-dark { color: rgb(255 255 255 / 0.9); }
.public-dark .card {
background: rgb(255 255 255 / 0.04);
border-color: rgb(255 255 255 / 0.1);
color: rgb(255 255 255 / 0.92);
box-shadow: var(--glass-shadow), inset 0 1px 0 rgb(255 255 255 / 0.07);
}
.public-dark .field {
background: rgb(255 255 255 / 0.04);
border-color: rgb(255 255 255 / 0.12);
color: rgb(255 255 255 / 0.95);
transition: border-color 0.18s, background 0.18s, box-shadow 0.18s;
}
.public-dark .field::placeholder { color: rgb(255 255 255 / 0.3); }
/* Single focus treatment shared by every <.field> across the admin shell —
a violet glow ring, matching the light theme's focus affordance. */
.public-dark .field:focus,
.public-dark .field:focus-visible {
border-color: rgb(139 92 246 / 0.7);
background: rgb(255 255 255 / 0.06);
box-shadow: 0 0 0 3px rgb(139 92 246 / 0.25);
outline: none;
}
.public-dark .btn-soft {
background: rgb(255 255 255 / 0.05);
border-color: rgb(255 255 255 / 0.12);
color: rgb(255 255 255 / 0.85);
}
.public-dark .btn-soft:hover { background: rgb(139 92 246 / 0.25); color: white; }
.public-dark .btn-ghost { color: rgb(255 255 255 / 0.65); border-color: transparent; }
.public-dark .btn-ghost:hover { background: rgb(139 92 246 / 0.18); color: white; }
.public-dark .btn-primary {
background: linear-gradient(120deg, #7c5cfc, #5f6cf8); color: #fff;
box-shadow: 0 6px 18px var(--accent-glow);
}
.public-dark .btn-primary:hover { filter: brightness(1.08); }
.public-dark .pill { background: rgb(255 255 255 / 0.06); color: rgb(255 255 255 / 0.85); border-color: rgb(255 255 255 / 0.12); }
/* Remap slate text tokens */
.public-dark .text-slate-900,
.public-dark .text-slate-800,
.public-dark .text-slate-700 { color: rgb(255 255 255 / 0.95); }
.public-dark .text-slate-600 { color: rgb(255 255 255 / 0.75); }
.public-dark .text-slate-500 { color: rgb(255 255 255 / 0.55); }
.public-dark .text-slate-400 { color: rgb(255 255 255 / 0.4); }
.public-dark .text-slate-300 { color: rgb(255 255 255 / 0.3); }
/* Backgrounds & borders that views used */
.public-dark .bg-white { background: rgb(255 255 255 / 0.12); color: rgb(255 255 255 / 0.95); }
.public-dark .bg-slate-50, .public-dark .bg-slate-50\/60 { background: rgb(255 255 255 / 0.025); }
.public-dark .bg-slate-100 { background: rgb(255 255 255 / 0.06); }
.public-dark .bg-slate-200 { background: rgb(255 255 255 / 0.1); }
.public-dark .border-slate-100,
.public-dark .border-slate-200,
.public-dark .border-slate-300 { border-color: rgb(255 255 255 / 0.08); }
.public-dark .ring-slate-200 { --tw-ring-color: rgb(255 255 255 / 0.08); }
/* Tables in dark */
.public-dark .th { color: rgb(255 255 255 / 0.4); }
.public-dark .row { border-color: rgb(255 255 255 / 0.06); }
.public-dark .row:hover { background: rgb(139 92 246 / 0.07); }
/* Inline code blocks (pill highlight) — only for inline <code>, NOT code inside
a <pre> block (docs examples), which would get an unwanted full-width tint. */
.public-dark code { background: rgb(255 255 255 / 0.08); color: rgb(255 255 255 / 0.9); }
.public-dark pre code { background: transparent; padding: 0; }
/* Filter pills + segmented controls that admin pages stamp with bg-slate-900
for the "selected" state — that lands invisible on the dark shell. Treat
slate-900 as a strong-contrast surface in the dark palette. */
.public-dark .bg-slate-900 { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); }
.public-dark .hover\:bg-slate-700:hover { background: rgb(255 255 255 / 0.85); color: rgb(15 23 42); }
.public-dark .text-slate-700 { color: rgb(255 255 255 / 0.85); }
.public-dark .text-slate-200 { color: rgb(255 255 255 / 0.7); }
.public-dark .text-slate-100 { color: rgb(255 255 255 / 0.92); }
/* Divider utilities used by lists (recent activity, table rows). */
.public-dark .divide-slate-50 > :not([hidden]) ~ :not([hidden]),
.public-dark .divide-slate-100 > :not([hidden]) ~ :not([hidden]) {
border-color: rgb(255 255 255 / 0.05);
}
.public-dark .border-slate-100\/80 { border-color: rgb(255 255 255 / 0.06); }
/* ── 空占位:图标块 + 文案,全站表格/卡片共用 ── */
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.75rem; padding: 4.5rem 1.5rem; color: var(--fg-3); text-align: center; }
.empty-ico { width: 3.5rem; height: 3.5rem; border-radius: 1rem; background: var(--surface-2); border: 1px solid var(--hairline); display: grid; place-items: center; color: var(--fg-3); }
.empty-text { font-size: 0.875rem; }
.empty-sub { font-size: 0.75rem; color: var(--fg-3); margin-top: -0.375rem; }
.public-dark .hover\:bg-slate-50:hover { background: rgb(255 255 255 / 0.04); }
.public-dark .hover\:bg-slate-100:hover { background: rgb(255 255 255 / 0.06); }
/* ===== 站名 logo 字:站酷快乐体(自托管子集)=====
同一字体名拆成两份:英文数字小文件 + 汉字大文件,unicode-range 限定后
只有站名里真的含汉字时才会下载汉字那份。 */
@font-face {
font-family: 'ZCOOL KuaiLe';
src: url('/fonts/zcool-kuaile-latin.woff2') format('woff2');
font-display: swap;
unicode-range: U+0020-007E;
}
@font-face {
font-family: 'ZCOOL KuaiLe';
src: url('/fonts/zcool-kuaile.woff2') format('woff2');
font-display: swap;
unicode-range: U+4E00-9FFF, U+3000-303F, U+FF00-FFEF, U+2014, U+2018-201D, U+2026;
}
.brand-word {
font-family: 'ZCOOL KuaiLe', ui-rounded, 'Hiragino Maru Gothic ProN', 'Arial Rounded MT Bold', sans-serif;
font-weight: 400;
letter-spacing: 0.03em;
color: var(--fg);
-webkit-text-stroke: 0.55px currentColor;
paint-order: stroke fill;
}
/* ===== 画图页选项胶囊的选中态(比例/画质/时长/张数):墨黑底白字,一眼能看出选中 ===== */
.chip-on {
background: #18181b !important;
color: #fff !important;
box-shadow: 0 4px 12px -4px rgb(24 24 27 / 0.45);
}
/* 生图 / 生视频 切换:容器稍深,选中项白底加描边与阴影 */
.mode-switch { background: #ececef !important; }
.mode-switch > .mode-on {
background: #fff !important;
color: #18181b !important;
font-weight: 700;
box-shadow: 0 1px 3px rgb(24 24 27 / 0.18), 0 0 0 1px rgb(24 24 27 / 0.08);
}
/* ===== 后台页头 + 统一工具栏 ===== */
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.page-title { font-size: 20px; font-weight: 800; letter-spacing: -0.03em; color: var(--fg); }
.page-sub { margin-top: 3px; font-size: 12.5px; color: var(--fg-3); line-height: 1.5; }
.page-sub strong { color: var(--fg-2); font-weight: 700; }
/* 刷新:独立的圆形图标按钮,固定在页头最右 */
.page-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.refresh-btn {
flex-shrink: 0;
width: 42px; height: 42px;
display: inline-grid; place-items: center;
border-radius: 50%;
border: 1px solid rgb(24 24 27 / 0.12);
background: #fff;
color: var(--fg-2);
box-shadow: 0 1px 2px rgb(24 24 27 / 0.04), 0 6px 16px -8px rgb(24 24 27 / 0.15);
transition: color .15s ease, border-color .15s ease, box-shadow .15s ease;
}
.refresh-btn svg { transition: transform .5s cubic-bezier(.22,.61,.36,1); }
.refresh-btn:hover { color: var(--fg); border-color: rgb(24 24 27 / 0.3); box-shadow: 0 8px 20px -8px rgb(24 24 27 / 0.25); }
.refresh-btn:hover svg { transform: rotate(180deg); }
.refresh-btn:active svg { transform: rotate(360deg); transition-duration: .25s; }
.page-bar { display: flex; justify-content: flex-end; }
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 10px 12px; }
.toolbar .field { height: 36px; padding-top: 0 !important; padding-bottom: 0 !important; font-size: 13px !important; }
.toolbar input.field:not([type]), .toolbar input.field[type="text"], .toolbar input.field[type="search"] {
padding-left: 34px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 11px center;
background-size: 16px;
}
.toolbar :is(.btn-soft, .btn-primary) { height: 36px; min-width: 88px; padding: 0 16px; font-size: 13px; font-weight: 700; box-shadow: none; }
.toolbar > .flex-1 { flex: 0 1 300px; min-width: 200px; }
.toolbar > .w-44 { flex: 0 1 240px; width: auto; }
.toolbar > .btn-primary { margin-left: auto; }
@layer components {
/* surfaces — frosted card with a hairline glass edge + soft shadow.
The inset top line is the light's "sheen" on the glass — barely
visible on white, reads as a subtle rim-light in dark mode. */
.card {
@apply rounded-2xl;
background: var(--surface);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow), inset 0 1px 0 rgb(255 255 255 / 0.55);
background: #fff;
border: 1px solid rgb(24 24 27 / 0.07);
box-shadow: 0 1px 2px rgb(24 24 27 / 0.03), 0 10px 28px -16px rgb(24 24 27 / 0.12);
}
/* buttons — each variant is self-contained (Tailwind v4 can't @apply a custom class) */
@@ -665,31 +471,28 @@ html.dark .memphis-dots {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-sm font-bold
disabled:opacity-50 disabled:cursor-not-allowed px-3.5 py-2;
color: #fff;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 60%, #22d3ee 140%);
border: 1px solid rgb(255 255 255 / 0.16);
box-shadow: 0 6px 18px var(--accent-glow);
transition: transform 0.13s ease, box-shadow 0.13s ease, filter 0.13s ease;
background: #18181b;
border: 1px solid #18181b;
box-shadow: 0 6px 16px -6px rgb(9 9 11 / 0.45);
transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.13s ease;
}
.btn-primary:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 10px 26px var(--accent-glow); filter: brightness(1.07); }
.btn-primary:active:not(:disabled) { transform: scale(0.97) translateY(1px); box-shadow: 0 3px 10px var(--accent-glow); }
.btn-primary:hover:not(:disabled) { background: #27272a; box-shadow: 0 10px 22px -8px rgb(9 9 11 / 0.5); }
.btn-primary:active:not(:disabled) { transform: scale(0.97); box-shadow: none; }
/* 禁用态:浅灰填充(vivid 同款),不再是变淡的黑块 */
.btn-primary:disabled { background: var(--surface-2); color: var(--fg-3); border-color: transparent; box-shadow: none; opacity: 1; }
.btn-soft {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-semibold
disabled:opacity-50 disabled:cursor-not-allowed px-3 py-1.5;
color: var(--fg-2);
background: var(--surface);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border: 1px solid var(--ink-soft);
transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
color: var(--fg);
background: #fff;
border: 1px solid rgb(24 24 27 / 0.12);
transition: transform 0.13s ease, background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.btn-soft:hover:not(:disabled) {
color: var(--fg);
background: var(--hover);
border-color: rgb(124 92 252 / 0.45);
box-shadow: 0 4px 14px rgb(124 92 252 / 0.16);
background: #fafafa;
border-color: rgb(24 24 27 / 0.3);
}
html.dark .btn-soft:hover:not(:disabled) { border-color: rgb(139 92 246 / 0.5); box-shadow: 0 4px 14px rgb(139 92 246 / 0.22); }
.btn-soft:active:not(:disabled) { transform: scale(0.97) translateY(1px); box-shadow: none; }
.btn-soft:active:not(:disabled) { transform: scale(0.97); }
.btn-ghost {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-medium
disabled:opacity-50 disabled:cursor-not-allowed px-3 py-1.5;
@@ -699,8 +502,7 @@ html.dark .memphis-dots {
}
.btn-ghost:hover:not(:disabled) {
color: var(--fg);
background: var(--hover);
border-color: rgb(124 92 252 / 0.3);
background: rgb(24 24 27 / 0.06);
}
/* text link / quiet action */
@@ -712,17 +514,18 @@ html.dark .memphis-dots {
@apply w-full rounded-lg px-3 py-2 text-sm
outline-none placeholder:text-slate-400;
color: var(--fg);
background: var(--surface);
border: 1px solid var(--ink-soft);
background: #fff;
border: 1px solid rgb(24 24 27 / 0.12);
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.field:hover { border-color: rgb(24 24 27 / 0.22); }
.field:focus, .field:focus-visible {
border-color: rgb(124 92 252 / 0.65);
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
border-color: #18181b;
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.08);
}
html.dark .field:focus, html.dark .field:focus-visible {
border-color: rgb(139 92 246 / 0.7);
box-shadow: 0 0 0 3px rgb(139 92 246 / 0.25);
border-color: rgb(24 24 27 / 0.7);
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.25);
}
/* Native <select> using .field: drop the OS arrow for a custom chevron that
@@ -750,20 +553,20 @@ html.dark .memphis-dots {
/* status pill */
.pill {
@apply inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold;
border: 1px solid var(--ink-soft);
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold;
border: 1px solid transparent;
}
/* table primitives */
.th { @apply text-left px-4 py-3 text-[11px] font-bold uppercase tracking-wider; color: var(--fg-faint); }
.th { @apply text-left px-4 py-3 text-[11px] font-bold uppercase tracking-wider; color: var(--fg-3); }
.td { @apply px-4 py-3.5 align-middle; }
.row { @apply border-t transition-colors; border-color: var(--hairline); }
.row:hover { background: rgb(124 92 252 / 0.06); }
.row:hover { background: #f7f7f8; }
/* avatar chip for identity columns */
.avatar {
@apply w-8 h-8 rounded-full grid place-items-center text-xs font-bold shrink-0;
border: 1px solid var(--ink);
background: var(--surface-2);
border: 1px solid rgb(24 24 27 / 0.1);
background: #f4f4f5;
}
}
+4 -43
View File
@@ -1,46 +1,7 @@
// Light/dark theme state. Default is LIGHT. The choice persists in localStorage
// and is reflected as a `dark` class on <html>, which drives the CSS-variable
// palette in style.css (and the conditional `.public-dark` override layer).
// 固定亮色主题:不再提供光/暗切换,并清掉历史上保存的暗色选择。
import { ref } from 'vue'
const KEY = 'gw_theme'
const mql = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null
localStorage.removeItem('gw_theme')
document.documentElement.classList.remove('dark')
function systemTheme() {
return mql && mql.matches ? 'dark' : 'light'
}
// Precedence: an explicit user choice (localStorage) wins; otherwise follow the
// OS's light/dark setting.
const saved = localStorage.getItem(KEY)
const initial = saved === 'dark' || saved === 'light' ? saved : systemTheme()
export const theme = ref(initial)
export const isDark = ref(initial === 'dark')
function apply(t) {
isDark.value = t === 'dark'
const el = document.documentElement
el.classList.toggle('dark', t === 'dark')
}
// Apply at module load so the first paint already matches the resolved choice.
apply(theme.value)
// While the user hasn't made an explicit choice, keep tracking the OS setting
// live (e.g. they flip macOS/Windows to dark mode with the tab open).
if (mql) {
mql.addEventListener('change', () => {
if (!localStorage.getItem(KEY)) {
theme.value = systemTheme()
apply(theme.value)
}
})
}
/** Flip light ⇄ dark and persist as the user's explicit choice. */
export function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
localStorage.setItem(KEY, theme.value)
apply(theme.value)
}
export const isDark = ref(false)
+63 -75
View File
@@ -7,27 +7,34 @@ const contact = computed(() => site.contact || {})
const powers = [
{ icon: 't2i', title: '文字生图', desc: '一句话出图,画幅与分辨率随模型可选。', tint: 'linear-gradient(135deg,#34d399,#2dd4bf)' },
{ icon: 'i2i', title: '参考图生图', desc: '上传参考换风格、改构图、做变体。', tint: 'linear-gradient(135deg,#e879f9,#f472b6)' },
{ icon: 't2v', title: '文字生视频', desc: '一段描述生成短片,时长分辨率可选。', tint: 'linear-gradient(135deg,#38bdf8,#22d3ee)' },
{ icon: 'i2v', title: '图生视频', desc: '给张参考图,让静态画面动起来。', tint: 'linear-gradient(135deg,#7c5cfc,#a855f7)' },
{ icon: 't2v', title: '文字生视频', desc: '一段描述生成短片,时长分辨率可选。', tint: 'linear-gradient(135deg,#38bdf8,#52525b)' },
{ icon: 'i2v', title: '图生视频', desc: '给张参考图,让静态画面动起来。', tint: 'linear-gradient(135deg,#18181b,#3f3f46)' },
]
const contactCards = computed(() => {
const c = contact.value || {}
const list = []
if (c.shop) list.push({ label: '商店', value: '前往商店', href: c.shop })
if (c.email) list.push({ label: '邮箱', value: c.email, href: `mailto:${c.email}` })
if (c.qq) list.push({ label: 'QQ', value: c.qq, href: c.qq_link || '' })
if (c.qq_group) list.push({ label: 'QQ 群', value: c.qq_group, href: c.qq_group_link || '' })
return list
})
</script>
<template>
<div class="space-y-16 pb-8">
<div class="space-y-14 pb-8">
<!-- ===== hero ===== -->
<section class="relative overflow-hidden rounded-[1.4rem] anime-card px-6 py-10 md:px-12 md:py-14">
<div class="memphis-dots absolute inset-0 opacity-40 pointer-events-none"></div>
<section class="ab-hero">
<div class="relative grid md:grid-cols-[1.1fr_0.9fr] gap-8 items-center">
<div>
<span class="sticker">✦ 关于我们</span>
<h1 class="mt-4 text-4xl md:text-5xl font-black tracking-tight text-[color:var(--fg)]">
把创作交给 <span class="anime-grad-text">{{ site.title }}</span>
</h1>
<p class="text-[color:var(--fg-2)] mt-5 text-base md:text-lg leading-relaxed max-w-xl">
{{ site.title }} 是一个聚合 GPT、Gemini、Firefly、Flux 等顶级模型的 AI 生图与生视频平台。
<span class="ab-eyebrow">✦ 关于我们</span>
<h1 class="ab-title">把创作交给 <span class="ab-brand">{{ site.title }}</span></h1>
<p class="ab-lead">
{{ site.title }} 是一个 AI 生图与生视频平台,汇集多家顶级模型,按实际生成量计费。
把脑海里的画面写成一句话,剩下的交给我们变成图像与视频。
</p>
<router-link to="/user" class="bubble-btn mt-7">开始创作 →</router-link>
<router-link to="/user" class="btn-primary ab-cta">开始创作 <span aria-hidden="true">→</span></router-link>
</div>
<!-- floating glass tile cluster standing in for a mascot illustration -->
<div class="ab-cluster" aria-hidden="true">
@@ -42,9 +49,9 @@ const powers = [
<!-- ===== powers ===== -->
<section>
<div class="anime-eyebrow text-[var(--m-orange)]">能做什么</div>
<h2 class="mt-3 text-2xl md:text-3xl font-black tracking-tight text-[color:var(--fg)]">四种方式,把想象变成画面</h2>
<div class="mt-7 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div class="ab-eyebrow">能做什么</div>
<h2 class="ab-h2">四种方式,把想象变成画面</h2>
<div class="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div v-for="p in powers" :key="p.title" class="power-card p-5">
<div class="power-ico" :style="{ background: p.tint }">
<svg v-if="p.icon === 't2i'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v14H4z"/><path d="M4 15l4.5-4.5 3 3L15 10l5 5"/><circle cx="9" cy="9" r="1.3"/></svg>
@@ -60,60 +67,45 @@ const powers = [
<!-- ===== contact ===== -->
<section>
<div class="anime-eyebrow text-sky-600">联系我们</div>
<h2 class="mt-3 text-2xl md:text-3xl font-black tracking-tight text-[color:var(--fg)]">有问题?来找我们玩~</h2>
<p class="text-[color:var(--fg-3)] mt-2 mb-6">合作、反馈,或想加入交流群,随时招呼。</p>
<div class="ab-eyebrow">联系我们</div>
<h2 class="ab-h2">有问题?来找我们玩~</h2>
<p class="ab-sub">合作、反馈,或想加入交流群,随时招呼。</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-3xl">
<a v-if="contact.shop" :href="contact.shop" target="_blank" rel="noopener"
class="group anime-card flex items-center justify-between gap-3 p-5">
<div>
<div class="sticker">商店</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 group-hover:text-[var(--m-orange)] transition-colors">前往商店</div>
<div class="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<component :is="c.href ? 'a' : 'div'" v-for="c in contactCards" :key="c.label"
:href="c.href || undefined" :target="c.href ? '_blank' : undefined" rel="noopener"
class="ct-card" :class="c.href && 'ct-link'">
<div class="ct-top">
<span class="ct-label">{{ c.label }}</span>
<span v-if="c.href" class="ct-arrow" aria-hidden="true">↗</span>
</div>
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
</a>
<a v-if="contact.email" :href="`mailto:${contact.email}`"
class="group anime-card p-5">
<div class="sticker">邮箱</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 break-all group-hover:text-sky-600 transition-colors">{{ contact.email }}</div>
</a>
<a v-if="contact.qq && contact.qq_link" :href="contact.qq_link" target="_blank" rel="noopener"
class="group anime-card flex items-center justify-between gap-3 p-5">
<div>
<div class="sticker">QQ</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all group-hover:text-sky-600 transition-colors">{{ contact.qq }}</div>
</div>
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
</a>
<div v-else-if="contact.qq" class="anime-card p-5">
<div class="sticker">QQ</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all">{{ contact.qq }}</div>
<div class="ct-value">{{ c.value }}</div>
</component>
</div>
<a v-if="contact.qq_group && contact.qq_group_link" :href="contact.qq_group_link" target="_blank" rel="noopener"
class="group anime-card flex items-center justify-between gap-3 p-5">
<div>
<div class="sticker">QQ 群</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all group-hover:text-emerald-600 transition-colors">{{ contact.qq_group }}</div>
</div>
<span class="text-[color:var(--fg-faint)] group-hover:translate-x-1 transition-transform">→</span>
</a>
<div v-else-if="contact.qq_group" class="anime-card p-5">
<div class="sticker">QQ 群</div>
<div class="text-base font-semibold text-[color:var(--fg)] mt-2 tabular-nums break-all">{{ contact.qq_group }}</div>
</div>
</div>
<p v-if="!contact.shop && !contact.email && !contact.qq && !contact.qq_group"
class="text-sm text-[color:var(--fg-3)]">管理员尚未配置联系方式。</p>
<p v-if="!contactCards.length" class="ab-sub">管理员尚未配置联系方式。</p>
</section>
</div>
</template>
<style scoped>
.ab-hero { position: relative; overflow: hidden; border-radius: 24px; padding: 48px 52px; background: var(--surface-solid); border: 1px solid rgb(24 24 27 / .07); box-shadow: 0 1px 2px rgb(24 24 27 / .03), 0 18px 40px -22px rgb(24 24 27 / .16); }
.ab-hero::before { content: ''; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(520px 300px at 92% 0%, rgba(251, 146, 60, .1), transparent 70%); }
.ab-eyebrow { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 800; letter-spacing: .08em; color: var(--fg-3); }
.ab-title { margin-top: 14px; font-size: clamp(32px, 4vw, 48px); font-weight: 800; line-height: 1.15; letter-spacing: -0.04em; color: var(--fg); }
.ab-brand { background: linear-gradient(100deg, #18181b, #71717a); -webkit-background-clip: text; background-clip: text; color: transparent; }
.ab-lead { margin-top: 18px; max-width: 34em; font-size: 16px; line-height: 1.8; color: var(--fg-2); }
.ab-cta { margin-top: 26px; height: 44px; padding: 0 24px !important; font-size: 14px !important; border-radius: 999px !important; }
.ab-h2 { margin-top: 8px; font-size: 26px; font-weight: 800; letter-spacing: -0.03em; color: var(--fg); }
.ab-sub { margin-top: 6px; font-size: 14px; color: var(--fg-3); }
.ct-card { display: flex; flex-direction: column; justify-content: space-between; gap: 18px; min-height: 108px; padding: 18px 20px; border-radius: 16px; background: var(--surface-solid); border: 1px solid rgb(24 24 27 / .07); text-decoration: none; transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease; }
.ct-link:hover { transform: translateY(-3px); border-color: rgb(24 24 27 / .3); box-shadow: 0 14px 30px -16px rgb(24 24 27 / .25); }
.ct-top { display: flex; align-items: center; justify-content: space-between; }
.ct-label { font-size: 12px; font-weight: 800; letter-spacing: .06em; color: var(--fg-3); }
.ct-arrow { font-size: 15px; color: var(--fg-faint); transition: transform .18s ease, color .18s ease; }
.ct-link:hover .ct-arrow { transform: translate(2px, -2px); color: var(--fg); }
.ct-value { font-size: 17px; font-weight: 800; letter-spacing: -0.01em; color: var(--fg); font-variant-numeric: tabular-nums; word-break: break-all; }
@media (max-width: 640px) { .ab-hero { padding: 30px 22px; } }
/* Floating glass tile cluster — a loose collage of rotated frosted squares. */
.ab-cluster {
position: relative;
@@ -125,19 +117,17 @@ const powers = [
place-items: center;
width: 5.5rem;
height: 5.5rem;
border: 1px solid var(--glass-border);
border: 1px solid rgb(24 24 27 / .07);
border-radius: 1.2rem;
background: var(--surface);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
box-shadow: var(--glass-shadow-lg);
background: var(--surface-solid);
box-shadow: 0 14px 30px -14px rgb(24 24 27 / .25);
color: var(--m-orange);
}
.ab-tile svg { width: 2.3rem; height: 2.3rem; }
.ab-1 { left: 12%; top: 6%; transform: rotate(-9deg); animation: memphis-float 6s ease-in-out infinite; }
.ab-2 { right: 14%; top: 0; color: var(--m-orange-2); transform: rotate(7deg); animation: memphis-float 7s ease-in-out infinite reverse; }
.ab-3 { left: 26%; bottom: 4%; color: var(--m-pink); transform: rotate(5deg); animation: memphis-bounce 4.5s ease-in-out infinite; }
.ab-4 { right: 8%; bottom: 14%; color: var(--m-mint); transform: rotate(-6deg); animation: memphis-float 5.5s ease-in-out infinite; }
.ab-2 { right: 14%; top: 0; color: #18181b; transform: rotate(7deg); animation: memphis-float 7s ease-in-out infinite reverse; }
.ab-3 { left: 26%; bottom: 4%; color: #52525b; transform: rotate(5deg); animation: memphis-bounce 4.5s ease-in-out infinite; }
.ab-4 { right: 8%; bottom: 14%; color: #18181b; transform: rotate(-6deg); animation: memphis-float 5.5s ease-in-out infinite; }
.ab-star {
position: absolute;
left: 50%; top: 44%;
@@ -146,15 +136,13 @@ const powers = [
animation: memphis-wiggle 3.5s ease-in-out infinite;
}
.power-card {
border: 1px solid var(--glass-border);
border: 1px solid rgb(24 24 27 / .07);
border-radius: 1.1rem;
background: var(--surface);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
box-shadow: var(--glass-shadow);
background: #fff;
box-shadow: 0 1px 2px rgb(24 24 27 / .03), 0 10px 28px -16px rgb(24 24 27 / .12);
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.power-card:hover { transform: translateY(-3px); border-color: rgb(124 92 252 / 0.45); box-shadow: var(--glass-shadow-lg); }
.power-card:hover { transform: translateY(-3px); border-color: rgb(24 24 27 / 0.45); box-shadow: var(--glass-shadow-lg); }
.power-ico {
width: 2.6rem;
height: 2.6rem;
@@ -162,7 +150,7 @@ const powers = [
display: grid;
place-items: center;
color: #fff;
box-shadow: 0 6px 16px rgb(124 92 252 / 0.28);
box-shadow: 0 6px 16px rgb(24 24 27 / 0.28);
}
.power-ico svg { width: 1.3rem; height: 1.3rem; }
@media (max-width: 768px) { .ab-cluster { display: none; } }
+34 -29
View File
@@ -4,6 +4,7 @@
import { ref, computed, onMounted } from 'vue'
import { api } from '../api'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
const items = ref([])
const total = ref(0)
@@ -27,6 +28,8 @@ function fmt(unix) {
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
const STATUS_OPTS = [{ value: '', label: '全部状态' }, { value: 'pending', label: '待支付' }, { value: 'paid', label: '已支付' }, { value: 'cancelled', label: '已取消' }]
const SOURCE_OPTS = [{ value: '', label: '全部来源' }, { value: 'epay', label: '在线充值' }, { value: 'admin', label: '后台充值' }, { value: 'cdk', label: '兑换码' }]
async function load() {
loading.value = true
const qs = new URLSearchParams({ limit: String(pageSize), offset: String((page.value - 1) * pageSize) })
@@ -73,28 +76,30 @@ function goPage(n) {
<template>
<section class="theme-text space-y-4">
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
<div class="page-head">
<div>
<h2 class="text-sm font-semibold">订单管理</h2>
<p class="text-xs text-white/45 mt-0.5">{{ total }} 笔充值订单</p>
<h2 class="page-title">订单管理</h2>
<p class="page-sub">{{ total }} 笔充值订单</p>
</div>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1.5">
<button v-for="s in [['','全部'],['pending','待支付'],['paid','已支付'],['cancelled','已取消']]" :key="s[0]"
@click="setStatus(s[0])" class="fp" :class="status === s[0] && 'fp-on'">{{ s[1] }}</button>
</div>
<div class="flex items-center gap-1.5">
<button v-for="s in [['','全部来源'],['epay','在线充值'],['admin','后台充值'],['cdk','兑换码']]" :key="s[0]"
@click="setSource(s[0])" class="fp" :class="source === s[0] && 'fp-on'">{{ s[1] }}</button>
</div>
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
class="field !py-1.5 text-xs !w-52" placeholder="搜索 订单号 / 用户名 / 金额…" />
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
<div class="page-actions">
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
</div>
<div v-if="loading && !items.length" class="card text-center text-sm text-white/40 py-20">加载中…</div>
<div v-else-if="!total" class="card text-center text-sm text-white/40 py-20">暂无订单</div>
<div class="card toolbar">
<SelectMenu :model-value="status" compact :options="STATUS_OPTS" @update:model-value="setStatus" />
<SelectMenu :model-value="source" compact :options="SOURCE_OPTS" @update:model-value="setSource" />
<div class="flex-1 min-w-[200px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
class="field" placeholder="搜索 订单号 / 用户名 / 金额…" />
</div>
</div>
<div v-if="loading && !items.length" class="card empty"><span class="empty-text">加载中…</span></div>
<div v-else-if="!total" class="card empty">
<span class="empty-ico"><Icon name="receipt" class="w-6 h-6" /></span>
<span class="empty-text">暂无订单</span>
</div>
<div v-else class="card overflow-x-auto !p-0">
<table class="w-full text-sm log-table min-w-[820px]">
@@ -116,7 +121,7 @@ function goPage(n) {
<td class="px-3 py-3.5 align-middle text-xs text-white/55 whitespace-nowrap">{{ fmt(o.created_at) }}</td>
<td class="px-3 py-3.5 align-middle text-xs text-white/55 whitespace-nowrap">{{ fmt(o.paid_at) }}</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/85">{{ o.source && o.source !== 'epay' ? '—' : '¥' + o.amount }}</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums" :class="o.points < 0 ? 'text-rose-300' : 'text-orange-300'">{{ o.points }}</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums" :class="o.points < 0 ? 'text-rose-600' : 'text-orange-600'">{{ o.points }}</td>
<td class="px-3 py-3.5 align-middle">
<span class="chip" :class="chipClass(o.status)">{{ STATUS[o.status] }}<span class="opacity-50 ml-1">· {{ METHOD[o.pay_type] || o.pay_type }}</span></span>
</td>
@@ -139,18 +144,18 @@ function goPage(n) {
</template>
<style scoped>
.fp { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.7rem; font-size: 0.72rem; border-radius: 0.55rem; color: rgb(255 255 255 / 0.65); background: rgb(255 255 255 / 0.05); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06); transition: background 0.15s, color 0.15s; }
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
.fp-emerald { background: rgb(16 185 129 / 0.22); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45); }
.fp-amber { background: rgb(245 158 11 / 0.22); color: rgb(252 211 77); box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45); }
.chip { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.18rem 0.55rem; font-size: 0.7rem; font-weight: 500; border-radius: 9999px; white-space: nowrap; background: rgb(255 255 255 / 0.06); color: rgb(255 255 255 / 0.55); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.1); }
.fp { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.7rem; font-size: 0.72rem; border-radius: 0.55rem; color: var(--fg-2); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--hairline); transition: background 0.15s, color 0.15s; }
.fp:hover { background: var(--hover); color: var(--fg); }
.fp-on { background: var(--surface-solid); color: rgb(15 23 42); box-shadow: none; }
.fp-emerald { background: rgb(16 185 129 / 0.1); color: #059669; box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.22); }
.fp-amber { background: rgb(245 158 11 / 0.12); color: #b45309; box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.25); }
.chip { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.18rem 0.55rem; font-size: 0.7rem; font-weight: 500; border-radius: 9999px; white-space: nowrap; background: var(--surface-2); color: var(--fg-3); box-shadow: inset 0 0 0 1px var(--hairline); }
.log-table { border-collapse: separate; border-spacing: 0; }
.log-row td { border-bottom: 1px solid rgb(255 255 255 / 0.04); transition: background-color 0.15s ease, box-shadow 0.15s ease; }
.log-row:hover td { background: rgb(255 255 255 / 0.025); }
.log-row td { border-bottom: 1px solid var(--hairline); transition: background-color 0.15s ease, box-shadow 0.15s ease; }
.log-row:hover td { background: var(--surface-2); }
.log-row:hover td:first-child { box-shadow: inset 2px 0 0 rgb(253 186 116 / 0.55); }
.log-row:last-child td { border-bottom: none; }
.pg { min-width: 1.75rem; padding: 0.3rem 0.55rem; font-size: 0.72rem; font-weight: 500; text-align: center; border-radius: 0.45rem; color: rgb(255 255 255 / 0.7); background: rgb(255 255 255 / 0.04); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); transition: background 0.15s, color 0.15s; }
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
.pg { min-width: 1.75rem; padding: 0.3rem 0.55rem; font-size: 0.72rem; font-weight: 500; text-align: center; border-radius: 0.45rem; color: var(--fg-2); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--hairline); transition: background 0.15s, color 0.15s; }
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
.pg-on { background: var(--surface-solid); color: rgb(15 23 42); box-shadow: none; }
</style>
+20 -12
View File
@@ -51,15 +51,20 @@ function goPage(n) {
<template>
<section class="theme-text space-y-4">
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
<div class="page-head">
<div>
<h2 class="text-sm font-semibold">违禁词触发列表</h2>
<p class="text-xs text-white/45 mt-0.5">{{ total }} 条触发记录 · 每次拦截记一条(违禁词 / 用户 / 时间 / 提示词)</p>
<h2 class="page-title">违禁词触发列表</h2>
<p class="page-sub">{{ total }} 条触发记录 · 每次拦截记一条(违禁词 / 用户 / 时间 / 提示词)</p>
</div>
<div class="flex items-center gap-2">
<div class="page-actions">
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
</div>
<div class="card toolbar">
<div class="flex-1 min-w-[200px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
class="field !py-1.5 text-xs !w-56" placeholder="搜索 违禁词 / 用户名 / 提示词…" />
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
class="field" placeholder="搜索 违禁词 / 用户名 / 提示词…" />
</div>
</div>
@@ -80,10 +85,13 @@ function goPage(n) {
</tr>
</thead>
<tbody>
<tr v-if="loading && !items.length"><td colspan="4" class="text-center text-xs text-white/40 py-10">加载中…</td></tr>
<tr v-else-if="!items.length"><td colspan="4" class="text-center text-xs text-white/40 py-10">{{ search.trim() ? '没有匹配的记录' : '还没有触发记录' }}</td></tr>
<tr v-if="loading && !items.length"><td colspan="4"><div class="empty"><span class="empty-text">加载中…</span></div></td></tr>
<tr v-else-if="!items.length"><td colspan="4"><div class="empty">
<span class="empty-ico"><Icon name="ban" class="w-6 h-6" /></span>
<span class="empty-text">{{ search.trim() ? '没有匹配的记录' : '还没有触发记录' }}</span>
</div></td></tr>
<tr v-for="h in items" :key="h.id" class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors align-top">
<td class="px-5 py-3.5 text-sm font-medium text-rose-300">{{ h.word }}</td>
<td class="px-5 py-3.5 text-sm font-medium text-rose-600">{{ h.word }}</td>
<td class="px-3 py-3.5">
<div class="text-sm text-white/85 truncate" :title="h.user_name">{{ h.user_name || '—' }}</div>
</td>
@@ -108,8 +116,8 @@ function goPage(n) {
</template>
<style scoped>
.pg { min-width: 1.75rem; padding: 0.3rem 0.55rem; font-size: 0.72rem; font-weight: 500; text-align: center; border-radius: 0.45rem; color: rgb(255 255 255 / 0.7); background: rgb(255 255 255 / 0.04); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); transition: background 0.15s, color 0.15s; }
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
.pg { min-width: 1.75rem; padding: 0.3rem 0.55rem; font-size: 0.72rem; font-weight: 500; text-align: center; border-radius: 0.45rem; color: var(--fg-2); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--hairline); transition: background 0.15s, color 0.15s; }
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
.pg-on { background: var(--surface-solid); color: rgb(15 23 42); box-shadow: none; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
</style>
+29 -20
View File
@@ -117,18 +117,24 @@ onMounted(load)
<template>
<section class="theme-text space-y-4">
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
<div class="page-head">
<div>
<h2 class="text-sm font-semibold">违禁词管理</h2>
<p class="text-xs text-white/45 mt-0.5">提示词包含违禁词的生成请求(画图台 + API)会被<strong class="text-white/70">直接拦截</strong>,并累计触发次数(见用户管理)。匹配不区分大小写。</p>
<h2 class="page-title">违禁词管理</h2>
<p class="page-sub">提示词包含违禁词的生成请求(画图台 + API)会被<strong>直接拦截</strong>,并累计触发次数(见用户管理)。匹配不区分大小写。</p>
</div>
<div class="flex items-center gap-2">
<div class="page-actions">
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
</div>
<div class="card toolbar">
<input v-model="newWord" @keyup.enter="add" class="field w-72" placeholder="输入违禁词后回车添加" />
<div class="ml-auto flex items-center gap-2">
<button v-if="selected.size" @click="delSelected" class="btn-soft danger shrink-0" title="删除选中的违禁词">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
</button>
<input v-model="newWord" @keyup.enter="add" class="field !py-1.5 text-xs w-52" placeholder="输入违禁词后回车" />
<button @click="add" class="btn-primary shrink-0">+ 添加</button>
<button @click="importOpen = true" class="btn-soft shrink-0">批量导入</button>
<button @click="add" class="btn-primary shrink-0">+ 添加</button>
</div>
</div>
@@ -146,14 +152,17 @@ onMounted(load)
</tr>
</thead>
<tbody>
<tr v-if="loading && !items.length"><td colspan="5" class="text-center text-xs text-white/40 py-10">加载中…</td></tr>
<tr v-else-if="!items.length"><td colspan="5" class="text-center text-xs text-white/40 py-10">还没有违禁词</td></tr>
<tr v-for="w in pagedItems" :key="w.id" class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
<tr v-if="loading && !items.length"><td colspan="5"><div class="empty"><span class="empty-text">加载中…</span></div></td></tr>
<tr v-else-if="!items.length"><td colspan="5"><div class="empty">
<span class="empty-ico"><Icon name="ban" class="w-6 h-6" /></span>
<span class="empty-text">还没有违禁词</span>
</div></td></tr>
<tr v-for="w in pagedItems" :key="w.id" class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors" :class="selected.has(w.id) && 'row-on'">
<td class="px-3 py-3.5 align-middle text-center">
<input type="checkbox" :checked="selected.has(w.id)" @change="toggleSelect(w.id)" @click.stop class="chk" />
</td>
<td class="px-5 py-3.5 align-middle text-sm font-medium text-white/90">{{ w.word }}</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums" :class="w.hits > 0 ? 'text-rose-300' : 'text-white/50'">{{ w.hits }}</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums" :class="w.hits > 0 ? 'text-rose-600' : 'text-white/50'">{{ w.hits }}</td>
<td class="px-3 py-3.5 align-middle text-xs text-white/50">{{ new Date(w.created_at).toLocaleString() }}</td>
<td class="px-3 py-3.5 align-middle text-right">
<button @click="del(w)" class="act danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
@@ -193,14 +202,14 @@ onMounted(load)
<style scoped>
.act {
display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem; border-radius: 0.5rem;
color: rgb(255 255 255 / 0.7); background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s, color 0.15s;
width: 1.9rem; height: 1.9rem; border-radius: 0.6rem;
color: var(--fg-3); background: var(--surface-solid);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
.act.danger { color: rgb(253 164 175); background: rgb(244 63 94 / 0.12); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); }
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
.act:hover { background: var(--surface-2); color: var(--fg); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.14); }
.act.danger:hover { background: rgb(244 63 94 / 0.08); color: #dc2626; box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.28); }
.btn-soft.danger {
color: rgb(253 164 175);
background: rgb(244 63 94 / 0.12);
@@ -211,9 +220,9 @@ onMounted(load)
background: rgb(244 63 94 / 0.25);
}
.chk { accent-color: rgb(244 63 94); width: 0.9rem; height: 0.9rem; cursor: pointer; }
.pg { min-width: 1.75rem; padding: 0.3rem 0.55rem; font-size: 0.72rem; font-weight: 500; text-align: center; border-radius: 0.45rem; color: rgb(255 255 255 / 0.7); background: rgb(255 255 255 / 0.04); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); transition: background 0.15s, color 0.15s; }
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
.pg { min-width: 1.75rem; padding: 0.3rem 0.55rem; font-size: 0.72rem; font-weight: 500; text-align: center; border-radius: 0.45rem; color: var(--fg-2); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--hairline); transition: background 0.15s, color 0.15s; }
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
.pg-on { background: var(--surface-solid); color: rgb(15 23 42); box-shadow: none; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
+119 -67
View File
@@ -7,6 +7,7 @@ import { api, jsonBody, loadImageBlob, loadImageData } from '../api'
import { auth } from '../auth'
import { normalizeDoc, newId, ratioToSize, serializeDoc, screenToWorld, rectsIntersect, DEFAULT_SCALE } from '../utils/canvas'
import CanvasStage from '../components/canvas/CanvasStage.vue'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
const stage = ref(null)
@@ -916,10 +917,11 @@ const saveLabel = computed(() => ({
站点固定顶栏(z-40)盖住,画布工具栏无法点击。 -->
<Teleport to="body">
<div class="cv-page">
<!-- 顶栏 -->
<!-- 悬浮岛:左上画布切换 / 右上撤销缩放 -->
<div class="cv-top">
<div class="cv-top__left">
<button class="cv-btn" title="返回" @click="$router.push('/user')">←</button>
<div class="cv-island cv-top__left">
<button class="cv-btn cv-btn--ghost" title="返回" @click="$router.push('/user')"><Icon name="arrow" :size="15" style="transform: rotate(180deg)" /></button>
<span class="cv-sep" />
<SelectMenu
class="cv-dd cv-dd--canvas"
:model-value="currentId"
@@ -927,26 +929,30 @@ const saveLabel = computed(() => ({
placeholder="选择画布"
@update:model-value="switchCanvas"
/>
<button class="cv-btn" title="重命名" @click="renameCanvas">✎</button>
<button class="cv-btn" title="新建画布" @click="newCanvas">+</button>
<button class="cv-btn cv-btn--danger" title="删除画布" @click="deleteCanvas">🗑</button>
</div>
<div class="cv-top__right">
<button class="cv-btn" :disabled="!undoCount" title="撤销 (Ctrl+Z)" @click="undo">↩</button>
<button class="cv-btn" :disabled="!redoCount" title="重做 (Ctrl+Shift+Z)" @click="redo">↪</button>
<span class="cv-sep" />
<button class="cv-btn" title="缩小 10%" @click="stage?.zoomStep(-1)">−</button>
<span class="cv-zoom">{{ scalePct }}%</span>
<button class="cv-btn" title="放大 10%" @click="stage?.zoomStep(1)">+</button>
<button class="cv-btn" title="重置视图 (0)" @click="stage?.setViewport({ x: 0, y: 0, scale: DEFAULT_SCALE })">⌂</button>
<button class="cv-btn" title="适应内容 (1)" @click="stage?.fitToContent?.()">⤢</button>
<span class="cv-sep" />
<button class="cv-btn cv-btn--accent" @click="addNote">+ 笔记</button>
<button class="cv-btn cv-btn--accent" @click="openPicker">🗂 图片库</button>
<input ref="fileInput" type="file" accept="image/*" multiple hidden @change="onFilePicked">
<button class="cv-btn cv-btn--ghost" title="重命名" @click="renameCanvas">✎</button>
<button class="cv-btn cv-btn--ghost" title="新建画布" @click="newCanvas"><Icon name="plus" :size="15" /></button>
<button class="cv-btn cv-btn--ghost cv-btn--danger" title="删除画布" @click="deleteCanvas"><Icon name="trash" :size="15" /></button>
<span v-if="saveLabel" class="cv-save" :data-state="saveState">{{ saveLabel }}</span>
</div>
<div class="cv-island cv-top__right">
<button class="cv-btn cv-btn--ghost" :disabled="!undoCount" title="撤销 (Ctrl+Z)" @click="undo">↩</button>
<button class="cv-btn cv-btn--ghost" :disabled="!redoCount" title="重做 (Ctrl+Shift+Z)" @click="redo">↪</button>
<span class="cv-sep" />
<button class="cv-btn cv-btn--ghost" title="缩小 10%" @click="stage?.zoomStep(-1)">−</button>
<span class="cv-zoom">{{ scalePct }}%</span>
<button class="cv-btn cv-btn--ghost" title="放大 10%" @click="stage?.zoomStep(1)"><Icon name="plus" :size="15" /></button>
<span class="cv-sep" />
<button class="cv-btn cv-btn--ghost" title="重置视图 (0)" @click="stage?.setViewport({ x: 0, y: 0, scale: DEFAULT_SCALE })">⌂</button>
<button class="cv-btn cv-btn--ghost" title="适应内容 (1)" @click="stage?.fitToContent?.()">⤢</button>
</div>
</div>
<!-- 左侧工具坞 -->
<div class="cv-tools">
<button class="cv-tool" title="添加笔记" @click="addNote"><Icon name="plus" :size="18" /><span>笔记</span></button>
<button class="cv-tool" title="从图片库添加" @click="openPicker"><Icon name="image" :size="18" /><span>图片库</span></button>
<input ref="fileInput" type="file" accept="image/*" multiple hidden @change="onFilePicked">
</div>
<!-- 舞台 + 生成面板 -->
@@ -1119,7 +1125,7 @@ const saveLabel = computed(() => ({
}
.cv-btn:hover:not(:disabled) {
background: var(--hover);
border-color: rgb(124 92 252 / 0.45);
border-color: rgb(24 24 27 / 0.45);
box-shadow: 0 4px 14px var(--accent-glow);
transform: translateY(-1px);
}
@@ -1136,14 +1142,14 @@ const saveLabel = computed(() => ({
box-shadow: 0 4px 14px rgb(229 72 77 / 0.25);
}
.cv-btn--accent {
background: linear-gradient(120deg, rgb(124 92 252 / 0.16), rgb(168 85 247 / 0.14));
border-color: rgb(124 92 252 / 0.42);
color: var(--m-orange);
background: var(--surface-solid);
border-color: rgb(24 24 27 / 0.28);
color: #18181b;
}
.cv-btn--accent:hover:not(:disabled) {
background: linear-gradient(120deg, rgb(124 92 252 / 0.26), rgb(168 85 247 / 0.22));
border-color: rgb(124 92 252 / 0.62);
box-shadow: 0 4px 16px var(--accent-glow);
background: rgb(24 24 27 / 0.05);
border-color: #18181b;
box-shadow: 0 4px 14px rgb(24 24 27 / 0.12);
}
.cv-select,
.cv-input {
@@ -1157,8 +1163,8 @@ const saveLabel = computed(() => ({
}
.cv-select:focus,
.cv-input:focus {
border-color: rgb(124 92 252 / 0.6);
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
border-color: rgb(24 24 27 / 0.6);
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.18);
}
.cv-page {
@@ -1171,25 +1177,77 @@ const saveLabel = computed(() => ({
background: var(--app-bg);
}
.cv-top {
position: relative;
position: absolute;
top: 14px;
left: 14px;
right: 14px;
z-index: 20; /* 下拉面板不能被舞台盖住 */
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 9px 14px;
border-bottom: 1px solid var(--ink-soft);
background: var(--surface);
pointer-events: none;
}
.cv-island {
pointer-events: auto;
padding: 6px;
border-radius: 16px;
border: 1px solid rgb(24 24 27 / 0.08);
background: var(--surface-solid);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
flex-wrap: wrap;
box-shadow: 0 1px 2px rgb(24 24 27 / 0.04), 0 12px 32px -12px rgb(24 24 27 / 0.2);
}
.cv-btn--ghost {
border-color: transparent;
background: transparent;
box-shadow: none;
-webkit-backdrop-filter: none;
backdrop-filter: none;
width: 32px;
padding: 0;
height: 32px;
border-radius: 10px;
}
.cv-tools {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
z-index: 15;
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
border-radius: 18px;
border: 1px solid rgb(24 24 27 / 0.08);
background: var(--surface-solid);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
box-shadow: 0 1px 2px rgb(24 24 27 / 0.04), 0 12px 32px -12px rgb(24 24 27 / 0.2);
}
.cv-tool {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
width: 56px;
padding: 9px 0 7px;
border: 0;
border-radius: 12px;
background: transparent;
color: var(--fg-2);
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.cv-tool:hover { background: rgb(24 24 27 / 0.06); color: #18181b; }
.cv-top__left,
.cv-top__right {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
gap: 2px;
}
/* 统一自定义下拉的宽度/紧凑度(SelectMenu 内部是 .field 按钮) */
.cv-dd {
@@ -1211,11 +1269,9 @@ const saveLabel = computed(() => ({
display: none;
}
.cv-sep {
width: 2.5px;
height: 20px;
border-radius: 2px;
background: var(--ink);
opacity: 0.25;
width: 1px;
height: 18px;
background: rgb(24 24 27 / 0.12);
margin: 0 4px;
}
.cv-zoom {
@@ -1224,10 +1280,10 @@ const saveLabel = computed(() => ({
color: var(--fg);
min-width: 46px;
text-align: center;
background: var(--hover);
border: 1px solid rgb(124 92 252 / 0.35);
border-radius: 999px;
padding: 1px 8px;
background: transparent;
border: 0;
border-radius: 8px;
padding: 1px 4px;
}
.cv-save {
font-size: 11px;
@@ -1266,9 +1322,9 @@ const saveLabel = computed(() => ({
font-size: 15px;
font-weight: 800;
color: var(--fg);
border: 2px dashed rgb(34 211 238 / 0.6);
border: 2px dashed rgb(24 24 27 / 0.6);
border-radius: 18px;
background: rgb(34 211 238 / 0.1);
background: rgb(24 24 27 / 0.1);
pointer-events: none;
}
.cv-loading,
@@ -1318,31 +1374,27 @@ const saveLabel = computed(() => ({
}
.cv-chip:hover {
background: var(--hover);
border-color: rgb(124 92 252 / 0.4);
border-color: rgb(24 24 27 / 0.4);
}
.cv-chip.is-on {
color: #fff;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
background: linear-gradient(120deg, #18181b, #27272a);
border-color: transparent;
box-shadow: 0 4px 14px var(--accent-glow);
}
/* 生成输入框:参考图缩略图在上,文本框居中,控制条在下 */
.cv-composer {
width: 100%;
border: 1px solid var(--glass-border);
border: 1px solid rgb(24 24 27 / 0.1);
border-radius: 22px;
background:
linear-gradient(160deg, rgb(124 92 252 / 0.06), transparent 40%),
var(--surface);
-webkit-backdrop-filter: blur(22px) saturate(1.15);
backdrop-filter: blur(22px) saturate(1.15);
box-shadow: var(--glass-shadow-lg), 0 0 42px var(--accent-glow);
background: #fff;
box-shadow: 0 2px 4px rgb(24 24 27 / 0.04), 0 18px 48px -12px rgb(24 24 27 / 0.2);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
overflow: visible; /* 下拉面板要从输入框上方弹出,不能被裁切 */
}
.cv-composer:focus-within {
border-color: rgb(124 92 252 / 0.55);
box-shadow: var(--glass-shadow-lg), 0 0 0 3px rgb(124 92 252 / 0.16), 0 0 42px var(--accent-glow);
border-color: rgb(24 24 27 / 0.55);
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.08), 0 18px 48px -12px rgb(24 24 27 / 0.24);
}
.cv-composer__refs {
display: flex;
@@ -1399,8 +1451,8 @@ const saveLabel = computed(() => ({
width: 56px;
height: 56px;
border-radius: 12px;
border: 1.5px dashed rgb(124 92 252 / 0.5);
background: rgb(124 92 252 / 0.04);
border: 1.5px dashed rgb(24 24 27 / 0.5);
background: rgb(24 24 27 / 0.04);
color: var(--m-orange);
display: flex;
flex-direction: column;
@@ -1414,7 +1466,7 @@ const saveLabel = computed(() => ({
}
.cv-refadd:hover {
border-color: var(--m-orange);
background: rgb(124 92 252 / 0.1);
background: rgb(24 24 27 / 0.1);
transform: translateY(-1px);
box-shadow: 0 6px 16px var(--accent-glow);
}
@@ -1468,7 +1520,7 @@ const saveLabel = computed(() => ({
padding: 10px;
border-radius: 11px;
border: 1px solid rgb(255 255 255 / 0.18);
background: linear-gradient(120deg, #7c5cfc, #5f6cf8 55%, #22d3ee 140%);
background: linear-gradient(120deg, #18181b, #27272a 55%, #52525b 140%);
color: #fff;
font-size: 14px;
font-weight: 800;
@@ -1593,14 +1645,14 @@ const saveLabel = computed(() => ({
}
.cv-picker__grid img:hover {
transform: translateY(-2px);
border-color: rgb(124 92 252 / 0.55);
border-color: rgb(24 24 27 / 0.55);
box-shadow: 0 8px 20px var(--accent-glow);
}
.cv-picker__up {
width: 100%;
aspect-ratio: 1;
border-radius: 10px;
border: 2px dashed rgb(124 92 252 / 0.45);
border: 2px dashed rgb(24 24 27 / 0.45);
background: transparent;
color: var(--m-orange);
display: flex;
@@ -1616,7 +1668,7 @@ const saveLabel = computed(() => ({
.cv-picker__up:hover {
transform: translateY(-2px);
border-color: var(--m-orange);
background: rgb(124 92 252 / 0.08);
background: rgb(24 24 27 / 0.08);
box-shadow: 0 8px 20px var(--accent-glow);
}
.cv-picker__up:disabled {
+50 -67
View File
@@ -5,6 +5,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { api, jsonBody } from '../api'
import { fmtTs } from '../utils/format'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
const items = ref([])
const stats = ref({ total: 0, active: 0, redeemed: 0, active_amount: 0, redeemed_amount: 0 })
@@ -14,7 +15,9 @@ const loading = ref(false)
const statusFilter = ref('') // '' | 'active'(未使用) | 'used'(已使用)
const typeFilter = ref('') // '' | 'normal' | 'marketing'
const search = ref('')
function setFilter(fn) { fn(); resetAndLoad() }
const TYPE_OPTS = [{ value: '', label: '全部类型' }, { value: 'normal', label: '普通' }, { value: 'marketing', label: '营销' }]
const STATUS_OPTS = [{ value: '', label: '所有状态' }, { value: 'active', label: '未使用' }, { value: 'used', label: '已使用' }]
watch([typeFilter, statusFilter], resetAndLoad)
function resetAndLoad() {
if (page.value !== 1) page.value = 1
else load()
@@ -140,23 +143,27 @@ const pageNumbers = computed(() => {
<template>
<section class="space-y-4">
<div class="page-head">
<h2 class="page-title">兑换码管理</h2>
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
<!-- KPI strip — same shape as LogsView / InvitesAdminView -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-white/45">总数</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">总数</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">未使用</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.active }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">未使用</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.active }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-white/35">已使用</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">已使用</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-white/60">{{ stats.redeemed }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-rose-300/80">未使用面额</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ Number(stats.active_amount || 0).toLocaleString('en-US') }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">未使用面额</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ Number(stats.active_amount || 0).toLocaleString('en-US') }}</div>
</div>
</div>
@@ -183,7 +190,7 @@ const pageNumbers = computed(() => {
</div>
<button @click="generate" class="btn-primary"><Icon name="plus" class="w-3.5 h-3.5" /> 生成</button>
</div>
<p v-if="form.type === 'marketing'" class="text-[11px] text-rose-300/80 mt-2">营销兑换码:同一批次每个用户只能兑换一次。</p>
<p v-if="form.type === 'marketing'" class="text-[11px] text-rose-600 mt-2">营销兑换码:同一批次每个用户只能兑换一次。</p>
<!-- last batch -->
<div v-if="lastBatch.length" class="mt-4 rounded-xl bg-white/[0.04] ring-1 ring-white/10 p-4">
@@ -198,38 +205,28 @@ const pageNumbers = computed(() => {
</div>
<!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1">
<button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button>
<button @click="setFilter(() => typeFilter = 'normal')" class="fp" :class="typeFilter === 'normal' && 'fp-on'">普通</button>
<button @click="setFilter(() => typeFilter = 'marketing')" class="fp" :class="typeFilter === 'marketing' && 'fp-rose'">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>营销
</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>未使用
</button>
<button @click="setFilter(() => statusFilter = 'used')" class="fp" :class="statusFilter === 'used' && 'fp-on'">已使用</button>
</div>
<div class="card toolbar">
<SelectMenu v-model="typeFilter" compact :options="TYPE_OPTS" />
<SelectMenu v-model="statusFilter" compact :options="STATUS_OPTS" />
<div class="flex-1 min-w-[160px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索兑换码…" />
</div>
<button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的兑换码">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
</button>
<button @click="load" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
</div>
<!-- table -->
<div class="card overflow-hidden">
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-16">加载中…</div>
<div v-else-if="!items.length && !stats.total" class="text-center text-sm text-white/40 py-16">还没有兑换码</div>
<div v-else-if="!items.length" class="text-center text-sm text-white/40 py-16">没有匹配的兑换码</div>
<div v-if="loading && !items.length" class="empty"><span class="empty-text">加载中…</span></div>
<div v-else-if="!items.length && !stats.total" class="empty">
<span class="empty-ico"><Icon name="key" class="w-6 h-6" /></span>
<span class="empty-text">还没有兑换码</span>
</div>
<div v-else-if="!items.length" class="empty">
<span class="empty-ico"><Icon name="key" class="w-6 h-6" /></span>
<span class="empty-text">没有匹配的兑换码</span>
</div>
<table v-else class="w-full text-sm">
<colgroup>
<col class="w-9" />
@@ -258,7 +255,8 @@ const pageNumbers = computed(() => {
</thead>
<tbody>
<tr v-for="c in pagedItems" :key="c.code"
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors"
:class="selected.has(c.code) && 'row-on'">
<td class="px-3 py-3.5 align-middle text-center">
<input type="checkbox" :checked="selected.has(c.code)" @change="toggleSelect(c.code)" @click.stop
class="chk" />
@@ -266,7 +264,7 @@ const pageNumbers = computed(() => {
<td class="px-5 py-3.5 align-middle font-mono text-xs text-white/90 truncate" :title="c.code">
<span class="inline-flex items-center gap-2">
<span class="truncate">{{ c.code }}</span>
<span v-if="c.type === 'marketing'" class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-sans font-medium bg-rose-500/15 text-rose-300 ring-1 ring-rose-400/25">营销</span>
<span v-if="c.type === 'marketing'" class="shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-sans font-medium bg-rose-500/15 text-rose-600 ring-1 ring-rose-500/25">营销</span>
</span>
</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/85 whitespace-nowrap">
@@ -275,10 +273,10 @@ const pageNumbers = computed(() => {
<td class="px-3 py-3.5 align-middle">
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="c.status === 'active'
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
? 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/25'
: 'bg-white/[0.06] text-white/55 ring-white/15'">
<span class="w-1.5 h-1.5 rounded-full"
:class="c.status === 'active' ? 'bg-emerald-400' : 'bg-slate-400'"></span>
:class="c.status === 'active' ? 'bg-emerald-500' : 'bg-slate-400'"></span>
{{ c.status === 'active' ? '未使用' : '已使用' }}
</span>
</td>
@@ -334,42 +332,27 @@ const pageNumbers = computed(() => {
/* row icon action buttons — mirror 账号管理 for a consistent look */
.act {
display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem;
border-radius: 0.5rem;
color: rgb(255 255 255 / 0.7);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s, color 0.15s;
width: 1.9rem; height: 1.9rem; border-radius: 0.6rem;
color: var(--fg-3); background: var(--surface-solid);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
.act.danger {
color: rgb(253 164 175);
background: rgb(244 63 94 / 0.12);
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
}
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
.act:hover { background: var(--surface-2); color: var(--fg); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.14); }
.act.danger:hover { background: rgb(244 63 94 / 0.08); color: #dc2626; box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.28); }
.fp {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.35rem 0.7rem; font-size: 0.72rem;
border-radius: 0.55rem;
color: rgb(255 255 255 / 0.65);
background: rgb(255 255 255 / 0.05);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
.fp-emerald {
background: rgb(16 185 129 / 0.22);
color: rgb(110 231 183);
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
}
.fp-rose {
background: rgb(244 63 94 / 0.22);
color: rgb(254 205 211);
box-shadow: inset 0 0 0 1px rgb(254 205 211 / 0.45);
}
.fp:hover { background: var(--hover); color: var(--fg); }
.fp-on { background: var(--surface-solid); color: rgb(15 23 42); box-shadow: none; }
.fp-emerald { background: rgb(16 185 129 / 0.1); color: #059669; box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.22); }
.fp-rose { background: rgb(239 68 68 / 0.09); color: #dc2626; box-shadow: inset 0 0 0 1px rgb(239 68 68 / 0.2); }
.pg {
min-width: 1.75rem;
@@ -378,14 +361,14 @@ const pageNumbers = computed(() => {
font-weight: 500;
text-align: center;
border-radius: 0.45rem;
color: rgb(255 255 255 / 0.7);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s;
}
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
.pg-on {
background: rgb(255 255 255 / 0.92);
background: var(--surface-solid);
color: rgb(15 23 42);
box-shadow: none;
}
+107 -123
View File
@@ -10,17 +10,20 @@ const channels = ref([])
const loading = ref(false)
const err = ref('')
const warn = ref('') // 保存后自动同步的非致命告警
const okMsg = ref('') // 同步模型成功提示
// create/edit form
const form = ref(null)
const saving = ref(false)
const formErr = ref('')
// fetch-models → 勾选即绑定(选中自动导入到模型列表,取消即解绑)
const fetchTarget = ref(null) // channel snapshot being imported into
// 同步上游模型弹窗:开关选择 + 别名,批量导入(保存渠道已自动全量同步)
const fetchTarget = ref(null) // saved channel being synced
const fetching = ref(false)
const fetchErr = ref('')
const specs = ref([])
const picked = ref(new Set())
const importing = ref(false)
async function load() {
loading.value = true
@@ -31,12 +34,6 @@ async function load() {
loading.value = false
}
function modelSummary(c) {
const names = c.models || []
if (!names.length) return ''
return names.length <= 2 ? names.join('、') : `${names[0]}、${names[1]} 等`
}
function openAdd() {
formErr.value = ''
form.value = { id: '', name: '', base_url: '', api_key: '', concurrency: 5, weight: 1, enabled: true }
@@ -54,6 +51,7 @@ async function save() {
if (saving.value) return
saving.value = true
formErr.value = ''
okMsg.value = ''
const r = await api('/channels', jsonBody('POST', {
id: f.id, name: f.name, base_url: f.base_url, api_key: f.api_key,
enabled: !!f.enabled, concurrency: f.concurrency || 0, weight: f.weight || 1,
@@ -83,59 +81,44 @@ async function del(c) {
channels.value = channels.value.filter((x) => x.id !== c.id)
}
// 表单内拉模型:编辑态走库内渠道(密钥不用重填);新渠道用表单里的地址+key 预览。
function fetchFromForm() {
const f = form.value
if (!f) return
if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return }
if (!f.id && !f.api_key) { formErr.value = '请填写 API Key'; return }
openFetch({ id: f.id || '', name: f.name || f.base_url, base_url: f.base_url, api_key: f.api_key, concurrency: f.concurrency, weight: f.weight, enabled: !!f.enabled })
}
// 行内「同步上游模型」走开关 + 别名弹窗(渠道已保存,用库里凭据拉取)。
// 默认全开:点「同步模型」把勾选项批量导入绑定;上游消失的模型由保存时的
// 全量同步负责解绑。
async function openFetch(c) {
fetchTarget.value = c
specs.value = []
picked.value = new Set()
fetchErr.value = ''
fetching.value = true
const r = await api('/channels/fetch-models', jsonBody('POST', {
id: c.id || '', base_url: c.base_url || '', api_key: c.api_key || '',
}))
const r = await api('/channels/fetch-models', jsonBody('POST', { id: c.id }))
fetching.value = false
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', busy: false }))
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: '' }))
picked.value = new Set(specs.value.map((s) => s.id))
}
// 勾选 = 立即导入绑定;取消勾选 = 解绑(模型没有其他渠道接入时本地行一并删除)。
// 新渠道尚未入库时,第一次勾选会先自动保存渠道再绑定。
async function toggleBind(s) {
if (s.busy) return
s.busy = true
function togglePick(id) {
const next = new Set(picked.value)
next.has(id) ? next.delete(id) : next.add(id)
picked.value = next
}
async function doImport() {
if (importing.value || !picked.value.size) return
const dup = specs.value.find((s) => picked.value.has(s.id) && s.alias && s.alias === s.id)
if (dup) { fetchErr.value = `${dup.id}的别名与上游模型名相同,别名只在需要改名时设置,同名请留空`; return }
const models = specs.value.filter((s) => picked.value.has(s.id))
.map((s) => { const { bound, ...rest } = s; return rest })
importing.value = true
fetchErr.value = ''
try {
const target = fetchTarget.value
if (!s.bound) {
if (!target.id) {
const sr = await api('/channels', jsonBody('POST', {
id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key,
enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1,
}))
if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return }
if (sr.data?.warning) warn.value = sr.data.warning
target.id = sr.data?.id
form.value = null
}
const { bound, busy, ...rest } = s
const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models: [rest] }))
const r = await api('/channels/import-models', jsonBody('POST', { id: fetchTarget.value.id, models }))
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
s.bound = true
} else {
const r = await api('/channels/unbind-model', jsonBody('POST', { id: target.id, model: s.id }))
if (!r.ok) { fetchErr.value = r.data?.detail || '解绑失败'; return }
s.bound = false
}
okMsg.value = `已导入 ${models.length} 个模型`
fetchTarget.value = null
load()
} finally {
s.busy = false
importing.value = false
}
}
@@ -143,8 +126,7 @@ function specText(s) {
const parts = []
if (s.qualities?.length) parts.push(s.qualities.join('/'))
if (s.duration) {
if (s.duration.mode === 'fixed') parts.push(`固定 ${s.duration.seconds}s`)
else if (s.duration.mode === 'range') parts.push(`${s.duration.min}-${s.duration.max}s`)
if (s.duration.mode === 'range') parts.push(`${s.duration.min}-${s.duration.max}s`)
else if (s.duration.mode === 'options') parts.push((s.duration.options || []).map((d) => `${d}s`).join('/'))
}
const refs = []
@@ -160,18 +142,21 @@ onMounted(load)
<template>
<section class="space-y-4">
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
<div class="page-head">
<div>
<div class="text-sm font-medium">渠道</div>
<p class="text-xs text-white/45 mt-0.5">同构 /v1 协议上游实例 · 保存时自动同步上游模型 · 余额实时探测</p>
<h2 class="page-title">渠道管理</h2>
<p class="page-sub">同构 /v1 协议上游实例 · 保存时自动同步上游模型 · 余额实时探测</p>
</div>
<div class="page-actions">
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
<button @click="openAdd" class="btn-primary">
<Icon name="plus" class="w-4 h-4" /> 添加渠道
</button>
</div>
<p v-if="err" class="text-xs text-rose-300">{{ err }}</p>
<p v-if="warn" class="text-xs text-amber-300">{{ warn }}</p>
<div class="page-bar"><button @click="openAdd" class="btn-primary"><Icon name="plus" class="w-4 h-4" /> 添加渠道</button></div>
<p v-if="err" class="text-xs text-rose-600">{{ err }}</p>
<p v-if="warn" class="text-xs text-amber-600">{{ warn }}</p>
<p v-if="okMsg" class="text-xs text-emerald-600">{{ okMsg }}</p>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
@@ -179,11 +164,11 @@ onMounted(load)
<thead>
<tr>
<th>渠道</th>
<th>上游地址</th>
<th>模型</th>
<th>并发</th>
<th>配比</th>
<th class="text-right">成功调用</th>
<th class="text-right">运行中</th>
<th class="text-right">成功数</th>
<th class="text-right">失败数</th>
<th class="text-right">累计消耗</th>
<th class="text-right">上游余额</th>
<th>状态</th>
@@ -197,34 +182,33 @@ onMounted(load)
<div class="font-medium text-white/85">{{ c.name }}</div>
<div class="text-[11px] text-white/35 font-mono">{{ c.id }}</div>
</td>
<td>
<div class="max-w-[240px] truncate font-mono text-xs text-white/55" :title="c.base_url">{{ c.base_url }}</div>
</td>
<td>
<div class="tabular-nums text-white/80">{{ (c.models || []).length }}</div>
<div class="text-[11px] text-white/35 max-w-[180px] truncate" :title="(c.models || []).join('、')">{{ modelSummary(c) }}</div>
</td>
<td class="tabular-nums">{{ c.concurrency || '默认' }}</td>
<td class="tabular-nums">{{ c.concurrency || '不限' }}</td>
<td class="tabular-nums">{{ c.weight || 1 }}</td>
<td class="text-right tabular-nums" :class="c.running_count > 0 ? 'text-emerald-600' : ''">{{ c.running_count || 0 }}</td>
<td class="text-right tabular-nums">{{ (c.success_total || 0).toLocaleString() }}</td>
<td class="text-right tabular-nums" :class="c.fail_count > 0 ? 'text-rose-600' : ''">{{ (c.fail_count || 0).toLocaleString() }}</td>
<td class="text-right tabular-nums">{{ (c.used_credits || 0).toLocaleString() }}</td>
<td class="text-right tabular-nums">{{ c.upstream_balance == null ? '-' : c.upstream_balance.toLocaleString() }}</td>
<td>
<button @click="toggle(c)" class="inline-flex items-center gap-1.5 text-xs"
:class="c.enabled ? 'text-emerald-300' : 'text-white/40'">
<span class="w-1.5 h-1.5 rounded-full" :class="c.enabled ? 'bg-emerald-400' : 'bg-white/25'"></span>
:class="c.enabled ? 'text-emerald-600' : 'text-white/40'">
<span class="w-1.5 h-1.5 rounded-full" :class="c.enabled ? 'bg-emerald-500' : 'bg-white/25'"></span>
{{ c.enabled ? '启用' : '停用' }}
</button>
</td>
<td class="text-xs text-white/45 whitespace-nowrap">{{ c.last_used_at ? fmtTs(c.last_used_at) : '-' }}</td>
<td class="text-right whitespace-nowrap">
<button @click="openFetch(c)" class="op" title="拉取上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button>
<button @click="openFetch(c)" class="op" title="同步上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button>
<button @click="openEdit(c)" class="op" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button>
<button @click="del(c)" class="op danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
</td>
</tr>
<tr v-if="loading"><td colspan="11" class="py-10 text-center text-white/40 text-sm">正在加载渠道…</td></tr>
<tr v-else-if="!channels.length"><td colspan="11" class="py-10 text-center text-white/40 text-sm">没有渠道,点右上角「添加渠道」接入上游实例</td></tr>
<tr v-if="loading"><td colspan="11"><div class="empty"><span class="empty-text">正在加载渠道…</span></div></td></tr>
<tr v-else-if="!channels.length"><td colspan="11"><div class="empty">
<span class="empty-ico"><Icon name="plug" class="w-6 h-6" /></span>
<span class="empty-text">还没有渠道</span>
<span class="empty-sub">点上方「添加渠道」接入上游实例</span>
</div></td></tr>
</tbody>
</table>
</div>
@@ -243,20 +227,20 @@ onMounted(load)
<input v-model.trim="form.name" type="text" maxlength="64" class="field" placeholder="例如:主站" />
</div>
<div>
<label class="lbl">上游地址 <span class="text-rose-300">*</span></label>
<label class="lbl">上游地址 <span class="text-rose-600">*</span></label>
<input v-model.trim="form.base_url" type="text" maxlength="300" class="field" placeholder="https://example.com" />
<p class="mt-1 text-[11px] text-white/35">同构 /v1 协议实例的站点地址,不带路径</p>
</div>
<div>
<label class="lbl">API Key <span v-if="!form.id" class="text-rose-300">*</span></label>
<label class="lbl">API Key <span v-if="!form.id" class="text-rose-600">*</span></label>
<input v-model.trim="form.api_key" type="text" class="field" :placeholder="form.id ? '留空保持不变' : 'vk_…'" autocomplete="off" />
<p class="mt-1 text-[11px] text-white/35">上游站点的用户 API Key(vk_ 前缀),密文存储,不回显</p>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="lbl">并发数</label>
<input v-model.number="form.concurrency" type="number" min="0" max="100" class="field" />
<p class="mt-1 text-[11px] text-white/35">0 = 默认 5</p>
<input v-model.number="form.concurrency" type="number" min="0" max="2147483647" class="field" />
<p class="mt-1 text-[11px] text-white/35">0 = 不限</p>
</div>
<div>
<label class="lbl">配比</label>
@@ -266,19 +250,12 @@ onMounted(load)
</div>
<div class="flex items-center gap-3">
<label class="lbl !mb-0">状态</label>
<button type="button" @click="form.enabled = !form.enabled" class="inline-flex items-center gap-1.5 text-xs"
:class="form.enabled ? 'text-emerald-300' : 'text-white/40'">
<span class="w-1.5 h-1.5 rounded-full" :class="form.enabled ? 'bg-emerald-400' : 'bg-white/25'"></span>
{{ form.enabled ? '已启用' : '已停用' }}
<button type="button" @click="form.enabled = !form.enabled" class="sw shrink-0" :class="{ 'sw-on': form.enabled }" role="switch" :aria-checked="form.enabled">
<span class="sw-thumb"></span>
</button>
<span class="text-xs" :class="form.enabled ? 'text-emerald-600' : 'text-[var(--fg-3)]'">{{ form.enabled ? '已启用' : '已停用' }}</span>
</div>
<div class="flex items-center gap-3">
<button type="button" @click="fetchFromForm" :disabled="fetching" class="btn-ghost text-xs">
{{ fetching ? '拉取中…' : '获取模型列表' }}
</button>
<span class="text-[11px] text-white/35">{{ form.id ? '用已保存的地址与密钥拉取' : '用上方的地址与 API Key 拉取;导入时会先自动保存渠道' }}</span>
</div>
<p v-if="formErr" class="text-xs text-rose-300">{{ formErr }}</p>
<p v-if="formErr" class="text-xs text-rose-600">{{ formErr }}</p>
</div>
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
<button @click="form = null" class="btn-ghost">取消</button>
@@ -287,42 +264,40 @@ onMounted(load)
</div>
</div>
<!-- 拉取上游模型 → 勾选即导入绑定 -->
<!-- 同步上游模型:开关选择 + 别名,批量导入 -->
<div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null">
<div class="card !shadow-2xl my-12 w-full max-w-2xl">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">上游模型 · {{ fetchTarget.name || fetchTarget.base_url }}</h2>
<h2 class="text-sm font-semibold">同步上游模型 · {{ fetchTarget.name || fetchTarget.base_url }}</h2>
<button @click="fetchTarget = null" class="text-white/40 hover:text-white transition-colors"><Icon name="close" class="w-5 h-5" /></button>
</div>
<div class="p-5">
<div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div>
<div v-if="fetching" class="empty !py-10"><span class="empty-text">正在从上游拉取模型列表…</span></div>
<template v-else>
<p class="text-xs text-white/45 mb-3">勾选即导入并绑定到本渠道,取消勾选即解绑(模型没有其他渠道接入时本地行一并删除)。能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名,绑定时生效)。注意:保存渠道会按上游清单全量重同步,手动解绑的模型若仍在上游列表中会被重新接入。</p>
<p v-if="fetchErr" class="text-xs text-rose-300 mb-3">{{ fetchErr }}</p>
<div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div>
<div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto">
<div v-for="s in specs" :key="s.id" @click="toggleBind(s)"
class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors"
:class="[!s.bound && 'opacity-50', s.busy && 'pointer-events-none']">
<span class="mt-1 inline-block h-3.5 w-3.5 shrink-0 rounded-full ring-1 transition-all"
:class="s.bound ? 'bg-sky-400 ring-sky-300' : 'bg-transparent ring-white/25'"></span>
<div class="flex-1 min-w-0 space-y-1.5">
<div class="flex items-center gap-2 flex-wrap">
<code class="font-mono text-xs text-white/85">{{ s.id }}</code>
<span class="px-1.5 py-0.5 rounded text-[10px] font-semibold"
:class="s.kind === 'video' ? 'bg-rose-400/15 text-rose-300' : 'bg-sky-400/15 text-sky-300'">
{{ KIND_LABEL[s.kind] || s.kind }}
</span>
<span class="text-[11px] text-white/40">{{ specText(s) }}</span>
<span v-if="s.busy" class="ml-auto text-[10px] text-white/40">处理中…</span>
<span v-else-if="s.bound" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已接入</span>
</div>
<div class="flex items-center gap-2" @click.stop>
<span class="text-[11px] text-white/35 shrink-0">别名</span>
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="s.bound"
class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40"
:placeholder="s.bound ? '已绑定,到模型管理改别名' : '留空 = 与上游同名'" />
<p class="text-xs text-white/45 mb-3 leading-relaxed">开启的模型会同步到本站模型列表,参数随上游带入;到「模型管理」配价后上架。别名即本站模型标识,留空 = 与上游同名。</p>
<p v-if="fetchErr" class="text-xs text-rose-600 mb-3">{{ fetchErr }}</p>
<div v-if="!specs.length" class="empty !py-10"><span class="empty-text">上游没有可售模型(模型需在上游已配价)</span></div>
<div v-else class="space-y-2 max-h-[400px] overflow-y-auto p-0.5">
<div v-for="s in specs" :key="s.id" @click="togglePick(s.id)"
class="p-3 rounded-xl ring-1 ring-white/10 bg-white/[0.02] hover:bg-white/[0.04] hover:ring-white/20 cursor-pointer transition-all space-y-2"
:class="!picked.has(s.id) && 'opacity-45'">
<div class="flex items-center gap-2.5 min-w-0">
<span class="w-2 h-2 rounded-full shrink-0"
:class="s.kind === 'video' ? 'bg-sky-500' : s.kind === 'text' ? 'bg-amber-500' : 'bg-zinc-500'"></span>
<code class="font-mono text-[13px] font-semibold text-white/90 truncate">{{ s.id }}</code>
<span class="text-[11px] font-semibold text-white/40 shrink-0">{{ KIND_LABEL[s.kind] || s.kind }}</span>
<button type="button" class="ml-auto sw" :class="picked.has(s.id) && 'sw-on'"
:aria-pressed="picked.has(s.id)" @click.stop="togglePick(s.id)">
<span class="sw-thumb"></span>
</button>
</div>
<div class="pl-[18px] text-[11px] text-white/50">{{ specText(s) }}</div>
<div class="pl-[18px]" @click.stop>
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="!picked.has(s.id)"
class="w-full px-2.5 py-1.5 rounded-lg bg-white/[0.03] border border-dashed border-white/15 text-xs text-white/80
hover:border-solid focus:border-solid focus:border-white/30 focus:bg-white/[0.05] focus:outline-none
disabled:opacity-40 disabled:cursor-not-allowed transition-all"
placeholder="别名(留空 = 与上游同名)" />
</div>
</div>
</div>
@@ -330,6 +305,9 @@ onMounted(load)
</div>
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
<button @click="fetchTarget = null" class="btn-ghost">关闭</button>
<button @click="doImport" :disabled="importing || !picked.size" class="btn-primary">
{{ importing ? '同步中…' : `同步模型(${picked.size})` }}
</button>
</div>
</div>
</div>
@@ -338,14 +316,20 @@ onMounted(load)
<style scoped>
.tbl { width: 100%; border-collapse: collapse; min-width: 1000px; }
.tbl thead th { padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 600; color: rgb(255 255 255 / 0.45); border-bottom: 1px solid rgb(255 255 255 / 0.06); white-space: nowrap; }
.tbl tbody td { padding: 10px 14px; border-bottom: 1px solid rgb(255 255 255 / 0.04); font-size: 12.5px; color: rgb(255 255 255 / 0.7); vertical-align: middle; }
.tbl thead th { padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 600; color: var(--fg-3); border-bottom: 1px solid var(--hairline); white-space: nowrap; }
.tbl tbody td { padding: 10px 14px; border-bottom: 1px solid var(--hairline); font-size: 12.5px; color: var(--fg-2); vertical-align: middle; }
.tbl tbody tr:last-child td { border-bottom: none; }
.tbl tbody tr:hover { background: rgb(255 255 255 / 0.02); }
.tbl tbody tr:hover { background: var(--surface-2); }
.lbl { display: block; font-size: 0.72rem; font-weight: 500; color: rgb(255 255 255 / 0.55); margin-bottom: 0.4rem; }
.lbl { display: block; font-size: 0.72rem; font-weight: 500; color: var(--fg-3); margin-bottom: 0.4rem; }
.op { display: inline-grid; place-items: center; min-width: 28px; height: 26px; padding: 0 7px; margin-left: 4px; border-radius: 7px; background: rgb(255 255 255 / 0.05); color: rgb(255 255 255 / 0.6); }
.op:hover { background: rgb(255 255 255 / 0.1); color: white; }
.op.danger:hover { background: rgb(244 63 94 / 0.15); color: rgb(251 113 133); }
.op { display: inline-grid; place-items: center; min-width: 28px; height: 28px; padding: 0 7px; margin-left: 4px; border-radius: 8px; background: var(--surface-solid); color: var(--fg-3); box-shadow: inset 0 0 0 1px var(--hairline); transition: background 0.15s, color 0.15s, box-shadow 0.15s; }
.op:hover { background: var(--surface-2); color: var(--fg); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.14); }
.op.danger:hover { background: rgb(244 63 94 / 0.08); color: #dc2626; box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.28); }
/* iOS-style switch for 模型开关 in the sync modal */
.sw { position: relative; width: 2rem; height: 1.15rem; border-radius: 9999px; background: var(--hover); box-shadow: inset 0 0 0 1px var(--hairline); transition: background 0.18s ease; flex-shrink: 0; }
.sw-thumb { position: absolute; top: 2px; left: 2px; width: calc(1.15rem - 4px); height: calc(1.15rem - 4px); border-radius: 9999px; background: var(--surface-solid); box-shadow: 0 1px 2px rgb(15 23 42 / 0.3); transition: transform 0.18s ease; }
.sw-on { background: #18181b; box-shadow: inset 0 0 0 1px #18181b; }
.sw-on .sw-thumb { transform: translateX(calc(2rem - 1.15rem)); }
</style>
+23 -16
View File
@@ -51,13 +51,17 @@ onMounted(load)
<template>
<section class="theme-text space-y-4">
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
<div class="page-head">
<div>
<h2 class="text-sm font-semibold">并发分组</h2>
<p class="text-xs text-white/45 mt-0.5">每个分组限制成员用户的<strong class="text-white/70">同时生成数</strong>(画图台 + API key 合计)。<strong class="text-white/70">0 = 不限制</strong>。新用户自动进入「默认注册分组」。</p>
<h2 class="page-title">并发分组</h2>
<p class="page-sub">每个分组限制成员用户的<strong>同时生成数</strong>(画图台 + API key 合计)。<strong>0 = 不限制</strong>。新用户自动进入「默认注册分组」。</p>
</div>
<button @click="openNew" class="btn-primary shrink-0">+ 新增分组</button>
<div class="page-actions">
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
</div>
<div class="page-bar"><button @click="openNew" class="btn-primary shrink-0"><Icon name="plus" class="w-4 h-4" /> 新增分组</button></div>
<div class="card overflow-hidden">
<table class="w-full text-sm">
@@ -71,18 +75,21 @@ onMounted(load)
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="5" class="text-center text-xs text-white/40 py-10">加载中…</td></tr>
<tr v-else-if="!items.length"><td colspan="5" class="text-center text-xs text-white/40 py-10">还没有分组</td></tr>
<tr v-if="loading"><td colspan="5"><div class="empty"><span class="empty-text">加载中…</span></div></td></tr>
<tr v-else-if="!items.length"><td colspan="5"><div class="empty">
<span class="empty-ico"><Icon name="layers" class="w-6 h-6" /></span>
<span class="empty-text">还没有分组</span>
</div></td></tr>
<tr v-for="g in items" :key="g.id" class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
<td class="px-5 py-3.5 align-middle text-sm font-medium text-white/90">{{ g.name }}</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums">
<span v-if="g.max_concurrency > 0" class="text-white/85">{{ g.max_concurrency }}</span>
<span v-else class="text-emerald-300/90">不限制</span>
<span v-else class="text-emerald-600">不限制</span>
</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/70">{{ g.user_count }}</td>
<td class="px-3 py-3.5 align-middle">
<span v-if="g.is_default" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium bg-rose-500/10 text-rose-300 ring-1 ring-rose-400/30">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span> 默认
<span v-if="g.is_default" class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium bg-rose-500/10 text-rose-600 ring-1 ring-rose-500/25">
<span class="w-1.5 h-1.5 rounded-full bg-rose-500"></span> 默认
</span>
<button v-else @click="setDefault(g)" class="btn-soft text-xs">设为默认</button>
</td>
@@ -130,14 +137,14 @@ onMounted(load)
<style scoped>
.act {
display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem; border-radius: 0.5rem;
color: rgb(255 255 255 / 0.7); background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s, color 0.15s;
width: 1.9rem; height: 1.9rem; border-radius: 0.6rem;
color: var(--fg-3); background: var(--surface-solid);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
.act.danger { color: rgb(253 164 175); background: rgb(244 63 94 / 0.12); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); }
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
.act:hover { background: var(--surface-2); color: var(--fg); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.14); }
.act.danger:hover { background: rgb(244 63 94 / 0.08); color: #dc2626; box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.28); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
+25 -16
View File
@@ -293,11 +293,20 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnounce
<template>
<section class="space-y-5">
<div class="page-head">
<div>
<h2 class="page-title">系统配置</h2>
<p class="page-sub">网站、支付、注册、邮件、代理等全站设置</p>
</div>
<div class="page-actions">
</div>
</div>
<!-- site -->
<div class="card p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold">网站</h2>
<span v-if="siteSaved" class="text-xs text-emerald-300">已保存 ✓</span>
<span v-if="siteSaved" class="text-xs text-emerald-600">已保存 ✓</span>
</div>
<div class="space-y-3">
<label class="row">
@@ -310,7 +319,7 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnounce
<div @click="pickLogo" @drop="onLogoDrop" @dragover.prevent="logoDragOver = true" @dragleave="logoDragOver = false"
title="点击或拖拽图片替换"
class="w-16 h-16 rounded-xl grid place-items-center overflow-hidden shrink-0 cursor-pointer transition-all"
:class="logoDragOver ? 'ring-2 ring-sky-400 bg-sky-50/40' : ''">
:class="logoDragOver ? 'ring-2 ring-sky-500/25 bg-sky-50/40' : ''">
<img v-if="logoPreview" :src="logoPreview" class="w-full h-full object-cover" />
<Logo v-else :size="64" class="w-full h-full" />
</div>
@@ -357,7 +366,7 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnounce
<div class="card p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold">注册与登录</h2>
<span v-if="regSaved" class="text-xs text-emerald-300">已保存 ✓</span>
<span v-if="regSaved" class="text-xs text-emerald-600">已保存 ✓</span>
</div>
<div class="space-y-3">
<label class="row">
@@ -400,7 +409,7 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnounce
<div class="card p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold">邮件服务 (SMTP)</h2>
<span v-if="smtpSaved" class="text-xs text-emerald-300">已保存 ✓</span>
<span v-if="smtpSaved" class="text-xs text-emerald-600">已保存 ✓</span>
</div>
<p class="text-xs text-slate-400 mb-4">用于发送注册 / 找回密码的验证码邮件。465 端口自动用 SSL,其余端口可选 STARTTLS。</p>
<div class="grid sm:grid-cols-2 gap-3">
@@ -431,7 +440,7 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnounce
</button>
</div>
<p v-if="!smtpConfigured" class="text-xs text-slate-400 mt-1.5">请先保存 SMTP 配置后再测试。</p>
<p v-else-if="testMsg" class="text-xs mt-1.5" :class="testOk ? 'text-emerald-300' : 'text-rose-300'">{{ testMsg }}</p>
<p v-else-if="testMsg" class="text-xs mt-1.5" :class="testOk ? 'text-emerald-600' : 'text-rose-600'">{{ testMsg }}</p>
</div>
</div>
@@ -439,7 +448,7 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnounce
<div class="card p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold">代理 (生图请求)</h2>
<span v-if="proxySaved" class="text-xs text-emerald-300">已保存 ✓</span>
<span v-if="proxySaved" class="text-xs text-emerald-600">已保存 ✓</span>
</div>
<p class="text-xs text-slate-400 mb-4">调用上游生成图片/视频时统一使用的 HTTP 代理,留空 = 直连。格式如 <code class="px-1 bg-slate-100 rounded">http://127.0.0.1:7890</code>。修改即时生效,无需重启。</p>
<input v-model="proxy.proxy" placeholder="留空 = 直连,如 http://127.0.0.1:7890" class="field" />
@@ -447,14 +456,14 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnounce
<button @click="saveProxy" :disabled="proxyBusy" class="btn-primary">{{ proxyBusy ? '保存中…' : '保存设置' }}</button>
<button @click="testProxy" :disabled="proxyTestBusy || !proxy.proxy.trim()" class="btn-ghost">{{ proxyTestBusy ? '测试中…' : '代理测试' }}</button>
</div>
<p v-if="proxyTest.msg" class="text-xs mt-2" :class="proxyTest.ok ? 'text-emerald-300' : 'text-rose-300'">{{ proxyTest.msg }}</p>
<p v-if="proxyTest.msg" class="text-xs mt-2" :class="proxyTest.ok ? 'text-emerald-600' : 'text-rose-600'">{{ proxyTest.msg }}</p>
</div>
<!-- rewards -->
<div class="card p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold">积分奖励</h2>
<span v-if="credSaved" class="text-xs text-emerald-300">已保存 ✓</span>
<span v-if="credSaved" class="text-xs text-emerald-600">已保存 ✓</span>
</div>
<div class="space-y-3">
<label class="row">
@@ -485,7 +494,7 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnounce
<div class="card p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold">去AI特征</h2>
<span v-if="deaiSaved" class="text-xs text-emerald-300">已保存 ✓</span>
<span v-if="deaiSaved" class="text-xs text-emerald-600">已保存 ✓</span>
</div>
<p class="text-xs text-slate-400 mb-4">画图台开启「去AI特征」后,按画质档位在模型价格之上额外扣除的积分。仅对图片生成生效。</p>
<div class="space-y-3">
@@ -571,7 +580,7 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnounce
<div class="card p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold">日志</h2>
<span v-if="logsSaved" class="text-xs text-emerald-300">已保存 ✓</span>
<span v-if="logsSaved" class="text-xs text-emerald-600">已保存 ✓</span>
</div>
<div class="space-y-3">
<label class="row">
@@ -592,7 +601,7 @@ onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnounce
<div class="card p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold">生成文件 (图片 / 视频)</h2>
<span v-if="mediaSaved" class="text-xs text-emerald-300">
<span v-if="mediaSaved" class="text-xs text-emerald-600">
已保存 ✓<span v-if="mediaRemoved" class="text-white/45"> · 立即清理 {{ mediaRemoved }} 个文件</span>
</span>
</div>
@@ -652,11 +661,11 @@ html.dark .hint b { color: rgb(253 164 175); } /* rose — visible warning, no
width: calc(1.3rem - 4px);
height: calc(1.3rem - 4px);
border-radius: 9999px;
background: white;
background: var(--surface-solid);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.2);
transition: transform 0.18s ease;
}
.sw:checked { background: #4f46e5; } /* sky-600 — matches btn-primary */
.sw:checked { background: #27272a; } /* sky-600 — matches btn-primary */
.sw:checked::after { transform: translateX(calc(2.25rem - 1.3rem)); }
.sw:focus-visible { box-shadow: 0 0 0 3px rgb(236 91 19 / 0.3); }
.sw:disabled { cursor: not-allowed; opacity: 0.55; }
@@ -677,8 +686,8 @@ html.dark .num, html.dark .txt, html.dark .field {
}
.num::placeholder, .txt::placeholder, .field::placeholder { color: var(--fg-faint); }
.num:focus, .txt:focus, .field:focus {
border-color: rgb(124 92 252 / 0.65);
box-shadow: 0 0 0 3px rgb(124 92 252 / 0.18);
border-color: rgb(24 24 27 / 0.65);
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.18);
}
.num:disabled, .txt:disabled, .field:disabled { opacity: 0.45; cursor: not-allowed; }
.num { width: 6rem; padding: 0.4rem 0.55rem; font-size: 0.8rem; text-align: right; -moz-appearance: textfield; appearance: textfield; }
@@ -692,7 +701,7 @@ html.dark .num, html.dark .txt, html.dark .field {
.btn-primary {
padding: 0.55rem 1.15rem; border-radius: 0.6rem;
font-size: 0.8rem; font-weight: 800; color: white;
background: linear-gradient(120deg, #7c5cfc, #5f6cf8);
background: linear-gradient(120deg, #18181b, #27272a);
border: 1px solid rgb(255 255 255 / 0.16);
box-shadow: 0 6px 18px var(--accent-glow);
transition: filter 0.15s, transform 0.12s, box-shadow 0.15s, opacity 0.15s;
+9 -6
View File
@@ -30,7 +30,7 @@ const videoModels = computed(() => models.value.filter((m) => m.type === 'video'
function pubName(m) {
return m?.alias || m?.id || ''
}
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'gpt-image-2')
const sampleImage = computed(() => pubName(imageModels.value[0]) || 'image-model')
const sampleVideo = computed(() => pubName(videoModels.value[0]) || 'video-model')
const sampleQuality = computed(() => (imageModels.value[0]?.resolutions || [])[0] || '1K')
const sampleVQuality = computed(() => (videoModels.value[0]?.resolutions || [])[0] || '720p')
@@ -209,7 +209,7 @@ async function copy(text) {
<dt class="text-white/45">你的 Key</dt><dd class="font-mono text-white/70">{{ keyHint }}</dd>
</div>
</dl>
<p class="text-[11px] text-white/40 mt-4">还没有 Key?去 <router-link to="/settings" class="text-orange-300 underline">设置 → API Key</router-link> 生成。</p>
<p class="text-[11px] text-white/40 mt-4">还没有 Key?去 <router-link to="/settings" class="text-orange-600 underline">设置 → API Key</router-link> 生成。</p>
</div>
<div class="card p-6">
@@ -229,7 +229,7 @@ async function copy(text) {
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<tr class="text-left text-[11px] uppercase tracking-wider text-[color:var(--fg-3)] border-b border-white/[0.08]">
<th class="px-4 py-3 font-medium whitespace-nowrap">model</th>
<th class="px-4 py-3 font-medium whitespace-nowrap">类型</th>
<th class="px-4 py-3 font-medium whitespace-nowrap" title="视频价 = 分辨率价 + 时长价">定价 <span class="normal-case text-white/30">积分</span></th>
@@ -275,7 +275,10 @@ async function copy(text) {
</div>
</td>
</tr>
<tr v-if="!models.length"><td colspan="4" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr>
<tr v-if="!models.length"><td colspan="4"><div class="empty">
<span class="empty-ico"><Icon name="models" class="w-6 h-6" /></span>
<span class="empty-text">暂无可用模型</span>
</div></td></tr>
</tbody>
</table>
</div>
@@ -287,7 +290,7 @@ async function copy(text) {
<h2 class="text-lg font-semibold mb-3">生成参数 <span class="text-xs font-normal text-white/40">POST /v1/generate</span></h2>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-[color:var(--fg-3)] border-b border-white/[0.08]">
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
</tr></thead>
<tbody>
@@ -306,7 +309,7 @@ async function copy(text) {
<h2 class="text-lg font-semibold mb-3">上传参数 <span class="text-xs font-normal text-white/40">POST /v1/uploads</span></h2>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-[color:var(--fg-3)] border-b border-white/[0.08]">
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
</tr></thead>
<tbody>
File diff suppressed because it is too large Load Diff
+22 -17
View File
@@ -207,24 +207,32 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<template>
<section class="space-y-4">
<div class="page-head">
<div>
<h2 class="page-title">作品管理</h2>
</div>
<div class="page-actions">
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
</div>
<!-- KPI strip — same shape as the LogsView so the admin shell stays
consistent. /images returns all four numbers in one payload. -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-white/45">总计</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">总计</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-sky-300/80">图像</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-sky-300">{{ stats.image }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">图像</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.image }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-rose-300/80">视频</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.video }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">视频</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.video }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">存储</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ fmtSize(stats.size_bytes) }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">存储</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ fmtSize(stats.size_bytes) }}</div>
</div>
</div>
@@ -247,19 +255,16 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ picked.size }})
</button>
</template>
<button @click="load" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
</div>
</div>
<!-- grid -->
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
<div v-else-if="!items.length" class="card flex flex-col items-center gap-3 text-white/40 py-20">
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
<div v-if="loading && !items.length" class="empty"><span class="empty-text">加载中…</span></div>
<div v-else-if="!items.length" class="card empty">
<span class="empty-ico">
<Icon name="files" class="w-6 h-6" />
</span>
<span class="text-sm">还没有生成过任何图片</span>
<span class="empty-text">还没有生成过任何图片</span>
</div>
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
@@ -325,10 +330,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums">
<span class="truncate" :title="f.model || ''">{{ f.model || '—' }}</span>
<span class="shrink-0 flex items-center gap-1">
<span v-if="f.resolution" class="text-emerald-300/90">{{ f.resolution }}</span>
<span v-if="f.resolution" class="text-emerald-600">{{ f.resolution }}</span>
<span v-if="f.ratio" class="text-white/40">{{ f.ratio }}</span>
<span v-if="f.kind === 'video' && f.duration" class="text-rose-300/80">{{ f.duration }}</span>
<span v-if="f.deai" class="text-[#a78bfa]">去AI</span>
<span v-if="f.kind === 'video' && f.duration" class="text-rose-600">{{ f.duration }}</span>
<span v-if="f.deai" class="text-[#71717a]">去AI</span>
</span>
</div>
<div class="text-[10px] text-white/35 mt-0.5 tabular-nums">{{ fmtSize(f.size) }} · {{ fmtTs(f.mtime) }}</div>
+80 -47
View File
@@ -77,59 +77,63 @@ function toast(m) {
</script>
<template>
<div class="theme-text space-y-10">
<div class="theme-text space-y-5">
<!-- header -->
<header>
<span class="sticker">✦ 奖励</span>
<h1 class="mt-3 text-4xl md:text-5xl font-bold tracking-tight">邀请好友</h1>
<p class="text-white/45 mt-2">好友用你的链接注册,并完成首次生图后,你得 {{ INVITE_REWARD }} 积分。</p>
</header>
<section class="relative rounded-2xl p-7 md:p-8 overflow-hidden ink-card">
<div class="memphis-dots absolute inset-0 opacity-30 pointer-events-none"></div>
<div class="relative inline-grid w-11 h-11 rounded-xl text-white place-items-center"
style="background: linear-gradient(135deg, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">
<Icon name="accounts" class="w-5 h-5" />
<div class="page-head">
<div>
<h2 class="page-title">邀请好友</h2>
<p class="page-sub">好友用你的链接注册,并完成首次生图后,你得 {{ INVITE_REWARD }} 积分。</p>
</div>
<!-- summary -->
<div class="relative mt-6 grid grid-cols-2 gap-3">
<div class="rounded-xl px-4 py-3 border border-[color:var(--glass-border)] bg-[var(--surface)] shadow-[var(--glass-shadow)]">
<div class="text-2xl font-black tabular-nums text-[color:var(--fg)]">{{ inviteCount }}</div>
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-widest font-bold">已邀请</div>
</div>
<div class="rounded-xl px-4 py-3 border border-[color:var(--glass-border)] bg-[var(--surface)] shadow-[var(--glass-shadow)]">
<div class="text-2xl font-black tabular-nums text-[color:var(--fg)]">{{ inviteEarned.toLocaleString('en-US') }}</div>
<div class="text-[10px] text-[color:var(--fg-3)] mt-1 uppercase tracking-widest font-bold">累计积分</div>
<div class="page-actions">
<button @click="loadRecords" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
</div>
<!-- code -->
<div class="relative mt-6">
<label class="block text-xs font-bold text-white/50 mb-2">邀请码</label>
<div class="flex gap-2">
<button @click="copyCode"
class="flex-1 rounded-xl border border-[color:var(--ink-soft)] bg-[var(--surface)] px-4 py-3 text-sm font-mono text-left shadow-[var(--glass-shadow)] hover:-translate-y-0.5 hover:border-[rgb(124_92_252/0.45)] transition-all">
{{ inviteCode || '—' }}
</button>
<button @click="copyInvite"
class="rounded-xl border border-white/20 text-white px-5 py-3 text-sm font-extrabold hover:-translate-y-0.5 active:translate-y-0.5 transition-all"
style="background: linear-gradient(120deg, #7c5cfc, #22d3ee); box-shadow: 0 8px 20px var(--accent-glow)">
复制链接
<!-- invite card -->
<section class="inv-card">
<div class="inv-main">
<div class="inv-label">你的专属邀请链接</div>
<div class="inv-link">
<span class="inv-url">{{ inviteUrl }}</span>
<button @click="copyInvite" class="inv-copy"><Icon name="copy" :size="15" /> 复制链接</button>
</div>
<button @click="copyCode" class="inv-code" title="点击复制邀请码">
<span class="inv-code-k">邀请码</span>
<span class="inv-code-v">{{ inviteCode || '—' }}</span>
<Icon name="copy" :size="14" />
</button>
</div>
<div class="mt-2 text-[11px] text-white/35 break-all font-mono">{{ inviteUrl }}</div>
<div class="inv-stats">
<div class="inv-stat">
<div class="inv-num">{{ inviteCount }}</div>
<div class="inv-k">已邀请</div>
</div>
<div class="inv-stat">
<div class="inv-num">{{ inviteEarned.toLocaleString('en-US') }}</div>
<div class="inv-k">累计积分</div>
</div>
</div>
</section>
<!-- steps -->
<section class="steps">
<div class="step">
<span class="step-n">1</span>
<div><div class="step-t">分享链接</div><div class="step-d">把专属链接或邀请码发给好友</div></div>
</div>
<div class="step">
<span class="step-n">2</span>
<div><div class="step-t">好友注册</div><div class="step-d">好友通过你的链接完成注册</div></div>
</div>
<div class="step">
<span class="step-n">3</span>
<div><div class="step-t">首次生图,你得 {{ INVITE_REWARD }} 积分</div><div class="step-d">好友完成第一张生图后自动到账</div></div>
</div>
</section>
<!-- records -->
<section>
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold">邀请记录</h2>
<button @click="loadRecords" class="text-xs text-white/50 hover:text-white inline-flex items-center gap-1.5 transition-colors">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
</div>
<h3 class="rec-title">邀请记录</h3>
<div class="card overflow-hidden">
<div v-if="loading && !records.length" class="text-center text-sm text-white/40 py-16">加载中…</div>
@@ -161,7 +165,7 @@ function toast(m) {
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
<td class="px-5 py-3.5 align-middle font-medium text-white/90 truncate">{{ r.name }}</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
:class="r.reward ? 'text-emerald-300' : 'text-white/25'">
:class="r.reward ? 'text-emerald-600' : 'text-white/25'">
{{ r.reward ? '+' + r.reward : '—' }}
</td>
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">
@@ -173,10 +177,10 @@ function toast(m) {
<td class="px-5 py-3.5 align-middle text-right">
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="r.status === 'completed'
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
: 'bg-amber-500/10 text-amber-300 ring-amber-400/30'">
? 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/25'
: 'bg-amber-500/10 text-amber-600 ring-amber-500/25'">
<span class="w-1.5 h-1.5 rounded-full"
:class="r.status === 'completed' ? 'bg-emerald-400' : 'bg-amber-400'"></span>
:class="r.status === 'completed' ? 'bg-emerald-500' : 'bg-amber-500'"></span>
{{ r.status === 'completed' ? '已完成' : '待生图' }}
</span>
</td>
@@ -212,6 +216,35 @@ function toast(m) {
</template>
<style scoped>
.inv-card {
display: flex; align-items: stretch; justify-content: space-between; gap: 28px; flex-wrap: wrap;
padding: 28px 30px; border-radius: 22px; position: relative; overflow: hidden;
background: radial-gradient(520px 260px at 100% 0%, rgba(251, 146, 60, 0.18), transparent 70%), linear-gradient(135deg, #27272a, #09090b);
box-shadow: 0 20px 44px -20px rgba(9, 9, 11, 0.55);
}
.inv-main { flex: 1 1 420px; min-width: 0; }
.inv-label { font-size: 12.5px; font-weight: 700; color: #a1a1aa; letter-spacing: .04em; }
.inv-link { margin-top: 12px; display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 16px; border-radius: 14px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.12); }
.inv-url { flex: 1; min-width: 0; font-family: ui-monospace, 'SF Mono', Consolas, monospace; font-size: 13px; color: #e4e4e7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.inv-copy { flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; height: 38px; padding: 0 16px; border: 0; border-radius: 10px; background: #fff; color: #18181b; font-size: 13px; font-weight: 800; cursor: pointer; transition: transform .12s ease, background .15s ease; }
.inv-copy:hover { background: #f4f4f5; transform: translateY(-1px); }
.inv-code { margin-top: 12px; display: inline-flex; align-items: center; gap: 10px; padding: 8px 14px; border-radius: 10px; border: 1px dashed rgba(255,255,255,.28); background: transparent; cursor: pointer; color: #a1a1aa; transition: border-color .15s ease, color .15s ease; }
.inv-code:hover { border-color: rgba(255,255,255,.55); color: #fff; }
.inv-code-k { font-size: 12px; font-weight: 700; }
.inv-code-v { font-family: ui-monospace, 'SF Mono', Consolas, monospace; font-size: 14px; font-weight: 700; color: #fff; letter-spacing: .06em; }
.inv-stats { display: flex; align-items: center; gap: 34px; padding-left: 8px; }
.inv-stat + .inv-stat { padding-left: 34px; border-left: 1px solid rgba(255,255,255,.14); }
.inv-num { font-size: 40px; line-height: 1; font-weight: 800; letter-spacing: -0.04em; color: #fff; font-variant-numeric: tabular-nums; }
.inv-k { margin-top: 8px; font-size: 12.5px; font-weight: 600; color: #a1a1aa; }
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.step { display: flex; align-items: center; gap: 14px; padding: 16px 18px; background: var(--surface-solid); border: 1px solid rgb(24 24 27 / .07); border-radius: 16px; }
.step-n { flex-shrink: 0; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: #f4f4f5; color: #18181b; font-size: 14px; font-weight: 800; }
.step-t { font-size: 14px; font-weight: 800; color: var(--fg); }
.step-d { margin-top: 2px; font-size: 12px; color: var(--fg-3); }
.rec-title { margin: 10px 2px 12px; font-size: 16px; font-weight: 800; letter-spacing: -0.02em; color: var(--fg); }
@media (max-width: 760px) { .steps { grid-template-columns: 1fr; } .inv-stats { padding-left: 0; } }
/* Numbered pagination buttons — dark base (matches the admin pages); the global
.theme-text rules in style.css recolor these for light mode automatically. */
.pg {
@@ -226,9 +259,9 @@ function toast(m) {
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
.pg-on {
background: rgb(255 255 255 / 0.92);
background: var(--surface-solid);
color: rgb(15 23 42);
box-shadow: none;
}
+25 -17
View File
@@ -20,35 +20,43 @@ onMounted(load)
<template>
<section class="space-y-4">
<div class="page-head">
<div>
<h2 class="page-title">邀请日志</h2>
<p class="page-sub">全站邀请记录:谁邀请了谁、注册与完成时间、奖励状态</p>
</div>
<div class="page-actions">
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
</div>
<!-- KPI strip — same shape as the LogsView so the admin shell stays
consistent. /invites already returns the stats payload. -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-white/45">总邀请</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">总邀请</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">已完成</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.completed }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">已完成</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.completed }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">待生图</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ stats.pending }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">待生图</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.pending }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-rose-300/80">已发奖励</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ Number(stats.reward_paid || 0).toLocaleString('en-US') }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">已发奖励</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ Number(stats.reward_paid || 0).toLocaleString('en-US') }}</div>
</div>
</div>
<div class="card overflow-hidden">
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
<div class="text-xs text-white/55">全站邀请记录:谁邀请了谁、注册与完成时间、奖励状态。</div>
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
</div>
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-16">加载中…</div>
<div v-else-if="!items.length" class="text-center text-sm text-white/40 py-16">还没有邀请记录</div>
<div v-if="loading && !items.length" class="empty"><span class="empty-text">加载中…</span></div>
<div v-else-if="!items.length" class="empty">
<span class="empty-ico"><Icon name="team" class="w-6 h-6" /></span>
<span class="empty-text">还没有邀请记录</span>
</div>
<table v-else class="w-full text-sm">
<colgroup>
@@ -75,7 +83,7 @@ onMounted(load)
<td class="px-5 py-3.5 align-middle font-medium text-white/90 truncate">{{ r.inviter }}</td>
<td class="px-3 py-3.5 align-middle text-white/75 truncate">{{ r.invitee }}</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
:class="r.reward ? 'text-emerald-300' : 'text-white/25'">
:class="r.reward ? 'text-emerald-600' : 'text-white/25'">
{{ r.reward ? '+' + r.reward : '—' }}
</td>
<td class="px-3 py-3.5 align-middle text-xs text-white/55 tabular-nums whitespace-nowrap">
@@ -87,10 +95,10 @@ onMounted(load)
<td class="px-5 py-3.5 align-middle text-right">
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="r.status === 'completed'
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
: 'bg-amber-500/10 text-amber-300 ring-amber-400/30'">
? 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/25'
: 'bg-amber-500/10 text-amber-600 ring-amber-500/25'">
<span class="w-1.5 h-1.5 rounded-full"
:class="r.status === 'completed' ? 'bg-emerald-400' : 'bg-amber-400'"></span>
:class="r.status === 'completed' ? 'bg-emerald-500' : 'bg-amber-500'"></span>
{{ r.status === 'completed' ? '已完成' : '待生图' }}
</span>
</td>
+58 -93
View File
@@ -5,6 +5,7 @@ import { fmtTs, fmtDate, fmtClock } from '../utils/format'
import { copyText } from '../utils/clipboard'
import { generatedUrl, thumbUrl } from '../api'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
const items = ref([])
@@ -14,7 +15,6 @@ const kindFilter = ref('') // '' | 'image' | 'video'
const statusFilter = ref('') // '' | 'success' | 'failed' | 'pending'
const sourceFilter = ref('') // '' | 'v1' | 'user' | 'admin'
const search = ref('')
const userSearch = ref('') // 服务端用户搜索:名称 / 邮箱 / ID,跨页生效
const page = ref(1)
const pageSize = ref(15)
const total = ref(0)
@@ -43,8 +43,7 @@ async function load() {
if (kindFilter.value) qs.set('kind', kindFilter.value)
if (statusFilter.value) qs.set('status', statusFilter.value)
if (sourceFilter.value) qs.set('source', sourceFilter.value)
if (userSearch.value.trim()) qs.set('user', userSearch.value.trim())
if (search.value.trim()) qs.set('q', search.value.trim())
if (search.value.trim()) qs.set('kw', search.value.trim())
const r = await api('/logs?' + qs.toString())
items.value = r.data?.data || []
total.value = Number(r.data?.total ?? items.value.length)
@@ -83,6 +82,9 @@ function goPage(n) {
}
// Filters reset the cursor so a narrower view always starts on page 1.
const KIND_OPTS = [{ value: '', label: '全部类型' }, { value: 'image', label: '图像' }, { value: 'video', label: '视频' }]
const STATUS_OPTS = [{ value: '', label: '所有状态' }, { value: 'success', label: '成功' }, { value: 'failed', label: '失败' }, { value: 'pending', label: '进行中' }]
const SOURCE_OPTS = [{ value: '', label: '所有来源' }, { value: 'user', label: '画图台' }, { value: 'v1', label: 'API' }, { value: 'admin', label: '测试' }]
function setKind(v) { kindFilter.value = v; page.value = 1; load() }
function setStatus(v) { statusFilter.value = v; page.value = 1; load() }
function setSource(v) { sourceFilter.value = v; page.value = 1; load() }
@@ -132,93 +134,68 @@ onUnmounted(() => {
// ---- chip helpers ----
const statusLabel = (s) => ({ success: '成功', failed: '失败', pending: '进行中' }[s] || s)
const statusPill = (s) => ({
success: 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30',
failed: 'bg-rose-500/10 text-rose-300 ring-rose-400/30',
pending: 'bg-amber-500/10 text-amber-300 ring-amber-400/30',
success: 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/25',
failed: 'bg-rose-500/10 text-rose-600 ring-rose-500/25',
pending: 'bg-amber-500/10 text-amber-600 ring-amber-500/25',
}[s] || 'bg-white/[0.06] text-white/65 ring-white/15')
const statusDot = (s) => ({
success: 'bg-emerald-400',
failed: 'bg-rose-400',
pending: 'bg-amber-400',
success: 'bg-emerald-500',
failed: 'bg-rose-500',
pending: 'bg-amber-500',
}[s] || 'bg-white/40')
// Source: backend stamps "v1" (API key), "user" (画图台), "admin" (后台测试模型).
const sourceLabel = (s) => ({ v1: 'API', user: '画图台', admin: '测试' }[s] || '画图台')
const sourcePill = (s) => ({
v1: 'bg-orange-500/15 text-orange-300 ring-orange-400/30',
admin: 'bg-amber-500/15 text-amber-300 ring-amber-400/30',
user: 'bg-sky-500/15 text-sky-300 ring-sky-400/30',
}[s] || 'bg-sky-500/15 text-sky-300 ring-sky-400/30')
v1: 'bg-orange-500/15 text-orange-600 ring-orange-500/25',
admin: 'bg-amber-500/15 text-amber-600 ring-amber-500/25',
user: 'bg-sky-500/15 text-sky-600 ring-sky-500/25',
}[s] || 'bg-sky-500/15 text-sky-600 ring-sky-500/25')
</script>
<template>
<section class="space-y-4">
<div class="page-head">
<h2 class="page-title">日志管理</h2>
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
<!-- KPI strip — dense pills aligned with the dashboard tints -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-white/45">总计</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">总计</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">成功</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.success }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">成功</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.success }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-rose-300/80">失败</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.failed }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">失败</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.failed }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">进行中</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ stats.pending }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">进行中</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.pending }}</div>
</div>
</div>
<!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1">
<button @click="setKind('')" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button>
<button @click="setKind('image')" class="fp" :class="kindFilter === 'image' && 'fp-on'">图像</button>
<button @click="setKind('video')" class="fp" :class="kindFilter === 'video' && 'fp-on'">视频</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="setStatus('')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
<button @click="setStatus('success')" class="fp" :class="statusFilter === 'success' && 'fp-emerald'">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>成功
</button>
<button @click="setStatus('failed')" class="fp" :class="statusFilter === 'failed' && 'fp-rose'">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>失败
</button>
<button @click="setStatus('pending')" class="fp" :class="statusFilter === 'pending' && 'fp-amber'">
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>进行中
</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="setSource('')" class="fp" :class="sourceFilter === '' && 'fp-on'">所有来源</button>
<button @click="setSource('user')" class="fp" :class="sourceFilter === 'user' && 'fp-on'">画图台</button>
<button @click="setSource('v1')" class="fp" :class="sourceFilter === 'v1' && 'fp-on'">API</button>
<button @click="setSource('admin')" class="fp" :class="sourceFilter === 'admin' && 'fp-on'">测试</button>
</div>
<div class="w-44">
<input v-model="userSearch" @keyup.enter="doUserSearch" @change="doUserSearch"
class="field !py-1.5 text-xs" placeholder="搜索用户 名称/邮箱/ID…" />
</div>
<div class="card toolbar">
<SelectMenu :model-value="kindFilter" compact :options="KIND_OPTS" @update:model-value="setKind" />
<SelectMenu :model-value="statusFilter" compact :options="STATUS_OPTS" @update:model-value="setStatus" />
<SelectMenu :model-value="sourceFilter" compact :options="SOURCE_OPTS" @update:model-value="setSource" />
<div class="flex-1 min-w-[200px]">
<input v-model="search" @keyup.enter="doUserSearch" @change="doUserSearch"
class="field !py-1.5 text-xs" placeholder="搜索 模型 / 提示词 / 错误…" />
class="field !py-1.5 text-xs" placeholder="搜索 用户 / 模型 / 提示词 / 错误…" />
</div>
<button @click="load" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
</div>
<!-- Table -->
<div class="card overflow-hidden">
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="files" class="w-6 h-6" /></span>
<span class="text-sm">{{ (search.trim() || userSearch.trim()) ? '没有匹配的记录' : '还没有日志' }}</span>
<div v-if="loading && !items.length" class="empty"><span class="empty-text">加载中…</span></div>
<div v-else-if="!filtered.length" class="empty">
<span class="empty-ico"><Icon name="files" class="w-6 h-6" /></span>
<span class="empty-text">{{ search.trim() ? '没有匹配的记录' : '还没有日志' }}</span>
</div>
<!-- Each row is a thumbnail + a stack of model/prompt + a meta line.
@@ -254,7 +231,7 @@ const sourcePill = (s) => ({
<td class="px-4 py-3.5 align-middle text-center">
<button v-if="e.status === 'success' && e.file && e.source !== 'v1'"
@click="openPreview(e)"
class="block w-12 h-12 mx-auto rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-rose-400/60 transition-all">
class="block w-12 h-12 mx-auto rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-rose-500/25 transition-all">
<img v-if="e.kind !== 'video' || !thumbFail[e.id]" :src="thumbUrl(e.file)" loading="lazy"
class="w-full h-full object-cover"
@error="e.kind === 'video' && (thumbFail[e.id] = true)" />
@@ -263,8 +240,8 @@ const sourcePill = (s) => ({
@mouseenter="$event.target.play && $event.target.play()"
@mouseleave="$event.target.pause && $event.target.pause()" />
</button>
<div v-else-if="e.status === 'pending'" class="w-12 h-12 mx-auto rounded-lg bg-amber-500/10 ring-1 ring-amber-400/30 grid place-items-center">
<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
<div v-else-if="e.status === 'pending'" class="w-12 h-12 mx-auto rounded-lg bg-amber-500/10 ring-1 ring-amber-500/25 grid place-items-center">
<span class="w-2 h-2 rounded-full bg-amber-500 animate-pulse"></span>
</div>
<!-- failed (and any non-success/non-pending) rows: no thumbnail, just a dash.
The 状态 column already flags the failure. -->
@@ -278,7 +255,7 @@ const sourcePill = (s) => ({
<span v-else class="text-white/25">—</span>
</td>
<td class="px-3 py-3.5 align-middle">
<span class="chip ring-1" :class="statusPill(e.status)">
<span class="chip ring-1" :class="statusPill(e.status)" :title="e.error || ''">
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(e.status)"></span>
{{ statusLabel(e.status) }}
</span>
@@ -291,7 +268,7 @@ const sourcePill = (s) => ({
<div class="font-mono text-xs text-white/90 break-all" :title="e.model">{{ e.model }}</div>
<div class="mt-1 flex items-center gap-1.5 min-w-0">
<span class="text-[10px] uppercase tracking-wider font-medium truncate min-w-0"
:class="e.kind === 'video' ? 'text-rose-300/80' : 'text-sky-300/80'">
:class="e.kind === 'video' ? 'text-rose-600' : 'text-sky-600'">
{{ e.kind === 'video' ? '视频' : '图像' }}
<span v-if="e.provider" class="text-white/30 ml-1">· {{ e.provider }}</span>
</span>
@@ -306,7 +283,7 @@ const sourcePill = (s) => ({
:class="e.prompt ? 'cursor-pointer hover:text-white' : ''"
:title="e.prompt ? '点击复制提示词' : ''"
@click="e.prompt && copyPrompt(e)">{{ e.prompt || '—' }}</div>
<div v-if="e.error" class="mt-1 text-[11px] text-rose-300/85 truncate flex items-center gap-1.5 cursor-pointer hover:text-rose-200 transition-colors"
<div v-if="e.error" class="mt-1 text-[11px] text-rose-600 truncate flex items-center gap-1.5 cursor-pointer hover:text-rose-500 transition-colors"
:title="e.error + ' — 点击复制'"
@click.stop="copyError(e)">
{{ e.error }}
@@ -327,11 +304,11 @@ const sourcePill = (s) => ({
</template>
<template v-if="e.deai">
<span class="text-white/25 mx-1.5">·</span>
<span class="text-[#a78bfa]">去AI特征</span>
<span class="text-[#71717a]">去AI特征</span>
</template>
</td>
<td class="px-3 py-3.5 text-right text-xs tabular-nums align-middle whitespace-nowrap">
<span v-if="e.cost > 0" class="text-amber-300 font-medium">{{ e.cost }}</span>
<span v-if="e.cost > 0" class="text-amber-600 font-medium">{{ e.cost }}</span>
<span v-else class="text-white/25">0</span>
</td>
<td class="px-4 py-3.5 text-right text-xs tabular-nums align-middle whitespace-nowrap text-white/85">
@@ -383,32 +360,20 @@ const sourcePill = (s) => ({
padding: 0.35rem 0.7rem;
font-size: 0.72rem;
border-radius: 0.55rem;
color: rgb(255 255 255 / 0.65);
background: rgb(255 255 255 / 0.05);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
.fp:hover { background: var(--hover); color: var(--fg); }
.fp-on {
background: rgb(255 255 255 / 0.92);
background: var(--surface-solid);
color: rgb(15 23 42);
box-shadow: none;
}
.fp-emerald {
background: rgb(16 185 129 / 0.22);
color: rgb(110 231 183);
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
}
.fp-rose {
background: rgb(244 63 94 / 0.22);
color: rgb(253 164 175);
box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45);
}
.fp-amber {
background: rgb(245 158 11 / 0.22);
color: rgb(252 211 77);
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45);
}
.fp-emerald { background: rgb(16 185 129 / 0.1); color: #059669; box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.22); }
.fp-rose { background: rgb(239 68 68 / 0.09); color: #dc2626; box-shadow: inset 0 0 0 1px rgb(239 68 68 / 0.2); }
.fp-amber { background: rgb(245 158 11 / 0.12); color: #b45309; box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.25); }
/* --- type / status chip used inside table rows --- */
.chip {
@@ -441,10 +406,10 @@ const sourcePill = (s) => ({
focus indicator without being noisy). --- */
.log-table { border-collapse: separate; border-spacing: 0; }
.log-row td {
border-bottom: 1px solid rgb(255 255 255 / 0.04);
border-bottom: 1px solid var(--hairline);
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.log-row:hover td { background: rgb(255 255 255 / 0.025); }
.log-row:hover td { background: var(--surface-2); }
.log-row:hover td:first-child {
box-shadow: inset 2px 0 0 rgb(253 186 116 / 0.55);
}
@@ -458,14 +423,14 @@ const sourcePill = (s) => ({
font-weight: 500;
text-align: center;
border-radius: 0.45rem;
color: rgb(255 255 255 / 0.7);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
.pg-on {
background: rgb(255 255 255 / 0.92);
background: var(--surface-solid);
color: rgb(15 23 42);
box-shadow: none;
}
+93 -211
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue'
import { api } from '../api'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
import ModelFormModal from '../components/ModelFormModal.vue'
import TestModal from '../components/TestModal.vue'
import { points } from '../credits'
@@ -15,6 +16,8 @@ const testing = ref(null) // model being tested, or null
const kindFilter = ref('') // '' | 'image' | 'video'
const saleFilter = ref('') // '' | 'sellable' | 'unsellable'
const KIND_OPTS = [{ value: '', label: '全部类型' }, { value: 'image', label: '图像' }, { value: 'video', label: '视频' }]
const SALE_OPTS = [{ value: '', label: '全部状态' }, { value: 'sellable', label: '已上架' }, { value: 'unsellable', label: '未上架' }]
const search = ref('')
const TYPE_LABEL = { image: '生图', video: '生视频' }
@@ -72,79 +75,65 @@ onMounted(loadModels)
<template>
<section class="space-y-4">
<div class="page-head">
<h2 class="page-title">模型管理</h2>
<button @click="loadModels" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
<!-- KPI strip — same shape as LogsView / InvitesAdminView -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-white/45">总数</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">总数</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-sky-300/80">图像</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-sky-300">{{ stats.image }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">图像</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.image }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-rose-300/80">视频</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.video }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">视频</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.video }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">上架</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.sellable }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">上架</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.sellable }}<span class="text-white/35 text-lg ml-1">/ {{ stats.total }}</span></div>
</div>
</div>
<!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1">
<button @click="kindFilter = ''" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button>
<button @click="kindFilter = 'image'" class="fp" :class="kindFilter === 'image' && 'fp-on'">图像</button>
<button @click="kindFilter = 'video'" class="fp" :class="kindFilter === 'video' && 'fp-on'">视频</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="saleFilter = ''" class="fp" :class="saleFilter === '' && 'fp-on'">全部</button>
<button @click="saleFilter = 'sellable'" class="fp" :class="saleFilter === 'sellable' && 'fp-emerald'">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>上架
</button>
<button @click="saleFilter = 'unsellable'" class="fp" :class="saleFilter === 'unsellable' && 'fp-white'">
<span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>未上架
</button>
</div>
<div class="card toolbar">
<SelectMenu v-model="kindFilter" compact :options="KIND_OPTS" />
<SelectMenu v-model="saleFilter" compact :options="SALE_OPTS" />
<div class="flex-1 min-w-[200px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / 别名…" />
</div>
<button @click="loadModels" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
</div>
<!-- Table -->
<div class="card overflow-hidden">
<div v-if="loading && !models.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center"><Icon name="models" class="w-6 h-6" /></span>
<span v-if="models.length" class="text-sm">没有匹配的模型</span>
<router-link v-else to="/admin/channels" class="text-sm text-sky-300 underline underline-offset-2">还没有模型,先到「渠道管理」勾选上游模型自动导入</router-link>
<div v-if="loading && !models.length" class="empty"><span class="empty-text">加载中…</span></div>
<div v-else-if="!filtered.length" class="empty">
<span class="empty-ico"><Icon name="models" class="w-6 h-6" /></span>
<span v-if="models.length" class="empty-text">没有匹配的模型</span>
<router-link v-else to="/admin/channels" class="empty-text underline underline-offset-2">还没有模型,先到「渠道管理」添加渠道,保存后会自动同步上游模型</router-link>
</div>
<table v-else class="w-full text-sm table-fixed">
<table v-else class="w-full text-sm table-fixed min-w-[1080px]">
<colgroup>
<col /> <!-- model id + provider -->
<col class="w-52" /> <!-- model name + provider -->
<col class="w-20" /> <!-- type -->
<col /> <!-- pricing -->
<col /> <!-- capability -->
<col class="w-20" /> <!-- weight -->
<col class="w-24" /> <!-- generation count -->
<col /> <!-- config -->
<col class="w-24" /> <!-- success count -->
<col class="w-20" /> <!-- sellable badge -->
<col class="w-36" /> <!-- actions -->
<col class="w-32" /> <!-- actions -->
</colgroup>
<thead>
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
<th class="text-left px-5 py-3 font-medium">模型</th>
<th class="text-left px-3 py-3 font-medium">类型</th>
<th class="text-left px-3 py-3 font-medium">定价</th>
<th class="text-left px-3 py-3 font-medium">能力</th>
<th class="text-right px-3 py-3 font-medium">权重</th>
<th class="text-right px-3 py-3 font-medium">生图次数</th>
<th class="text-left px-3 py-3 font-medium">配置</th>
<th class="text-right px-3 py-3 font-medium">成功次数</th>
<th class="text-left px-3 py-3 font-medium">上架</th>
<th class="text-right px-5 py-3 font-medium">操作</th>
</tr>
@@ -152,103 +141,65 @@ onMounted(loadModels)
<tbody>
<tr v-for="m in filtered" :key="m.id"
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
<!-- Model id + provider underneath -->
<!-- Model name (mono) + provider / weight subline -->
<td class="px-5 py-3.5 align-middle min-w-0">
<div class="flex items-start gap-2 min-w-0">
<div class="font-mono text-xs text-white/90 truncate" :title="m.id">{{ m.id }}</div>
<span v-if="m.alias" class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] bg-sky-500/10 text-sky-300 ring-1 ring-sky-400/20 shrink-0">{{ m.alias }}</span>
<div class="font-mono text-[12.5px] text-white/90 truncate" :title="m.id">{{ m.alias || m.name || m.id }}</div>
<div class="mt-1 text-[10.5px] text-white/45 truncate" :title="(m.channels || []).join('、')">
{{ (m.channels || []).length ? m.channels.join('、') : (m.provider === 'channel' ? '渠道导入' : (m.provider || '—')) }} · 权重 {{ m.weight || 0 }}
</div>
<div class="mt-1 text-[10px] text-white/45 truncate">{{ m.provider === 'channel' ? '渠道导入' : (m.provider || '—') }}</div>
</td>
<!-- Type chip with the same shape as Logs/Provider 健康 -->
<!-- Type tag -->
<td class="px-3 py-3.5 align-middle">
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="m.type === 'video'
? 'bg-rose-500/10 text-rose-300 ring-rose-400/30'
: 'bg-sky-500/10 text-sky-300 ring-sky-400/30'">
<span class="kind-tag" :class="m.type === 'video' ? 'kind-video' : 'kind-image'">
{{ TYPE_LABEL[m.type] || m.type }}
</span>
</td>
<!-- Pricing block — image shows 1K/2K/4K + price, video shows
duration + price; each as a small price-chip. -->
<!-- Pricing — compact chips: 档位 + 普通价 + 代理价 -->
<td class="px-3 py-3.5 align-middle">
<!-- Each tier shows a pair: 普通价 (emerald) + 代理价 (amber).
代理价未设置时回退普通价数值。 -->
<div v-if="m.type === 'image'" class="flex flex-wrap gap-1">
<span v-for="r in (m.resolutions || [])" :key="r" class="price-chip">
<span class="text-white/85">{{ r }}</span>
<span class="text-white/30 mx-1">普通</span>
<span class="text-emerald-300 tabular-nums">{{ points(m.prices?.[r]) }}</span>
<span class="text-amber-300/40 ml-1.5">代理</span>
<span class="text-amber-300 tabular-nums">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</span>
<div v-if="m.type === 'image'" class="flex flex-wrap gap-1.5">
<span v-for="r in (m.resolutions || [])" :key="r" class="p-chip">
<b>{{ r }}</b><em>{{ points(m.prices?.[r]) }}</em><em class="ag">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</em>
</span>
<span v-if="!(m.resolutions || []).length" class="text-white/30 text-xs">—</span>
</div>
<div v-else class="flex flex-wrap gap-1">
<span v-for="r in (m.resolutions || [])" :key="'r'+r" class="price-chip">
<span class="text-white/85">{{ r }}</span>
<span class="text-white/30 mx-1">普通</span>
<span class="text-emerald-300 tabular-nums">{{ points(m.prices?.[r]) }}</span>
<span class="text-amber-300/40 ml-1.5">代理</span>
<span class="text-amber-300 tabular-nums">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</span>
<div v-else class="flex flex-wrap gap-1.5">
<span v-for="r in (m.resolutions || [])" :key="'r'+r" class="p-chip">
<b>{{ r }}</b><em>{{ points(m.prices?.[r]) }}</em><em class="ag">{{ points(m.prices_agent?.[r] ?? m.prices?.[r]) }}</em>
</span>
<span v-if="m.duration_prices?.per_second != null" class="price-chip">
<span class="text-white/85">/s</span>
<span class="text-white/30 mx-1">普通</span>
<span class="text-sky-300 tabular-nums">{{ points(m.duration_prices.per_second) }}</span>
<span class="text-amber-300/40 ml-1.5">代理</span>
<span class="text-amber-300 tabular-nums">{{ points(m.duration_prices_agent?.per_second ?? m.duration_prices.per_second) }}</span>
<span v-if="m.duration_prices?.per_second != null" class="p-chip">
<b>/s</b><em>{{ points(m.duration_prices.per_second) }}</em><em class="ag">{{ points(m.duration_prices_agent?.per_second ?? m.duration_prices.per_second) }}</em>
</span>
<span v-else v-for="d in (m.durations || [])" :key="'d'+d" class="price-chip">
<span class="text-white/85">{{ d }}</span>
<span class="text-white/30 mx-1">普通</span>
<span class="text-sky-300 tabular-nums">+{{ points(m.duration_prices?.[d]) }}</span>
<span class="text-amber-300/40 ml-1.5">代理</span>
<span class="text-amber-300 tabular-nums">+{{ points(m.duration_prices_agent?.[d] ?? m.duration_prices?.[d]) }}</span>
<span v-else v-for="d in (m.durations || [])" :key="'d'+d" class="p-chip">
<b>{{ d }}</b><em>+{{ points(m.duration_prices?.[d]) }}</em><em class="ag">+{{ points(m.duration_prices_agent?.[d] ?? m.duration_prices?.[d]) }}</em>
</span>
<span v-if="!(m.resolutions || []).length && !(m.durations || []).length && m.duration_prices?.per_second == null" class="text-white/30 text-xs">—</span>
</div>
</td>
<!-- Capability — duration range, frame/ref modes, media support -->
<!-- 配置 — uniform mono tags: duration, resolutions, refs, ratios -->
<td class="px-3 py-3.5 align-middle">
<div class="flex flex-wrap items-center gap-1 text-[11px]">
<!-- Duration range (4s–15s) -->
<span v-if="m.type === 'video' && (m.durations || []).length >= 2" class="cap-chip cap-dur">{{ m.durations[0] }}–{{ m.durations[m.durations.length - 1] }}</span>
<span v-else-if="m.type === 'video' && (m.durations || []).length === 1" class="cap-chip cap-dur">{{ m.durations[0] }}</span>
<!-- Resolutions for image -->
<span v-if="m.type === 'image'" v-for="r in (m.resolutions || [])" :key="'ir'+r" class="cap-chip cap-slate">{{ r }}</span>
<!-- Resolutions for video -->
<span v-if="m.type === 'video'" v-for="r in (m.resolutions || [])" :key="'vr'+r" class="cap-chip cap-slate">{{ r }}</span>
<!-- Frame mode (首尾帧 2) -->
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 0" class="cap-chip cap-frame">首尾帧 {{ Math.min(m.max_reference_images, 2) }}</span>
<!-- Reference image mode (参考图 9) -->
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cap-chip cap-ref">参考图 {{ refImages(m) }}</span>
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cap-chip cap-ref">参考图 {{ refImages(m) }}</span>
<span v-else-if="m.type === 'image' && m.image_to_image" class="cap-chip cap-ref">参考图</span>
<!-- 视频/音频参考上限,来自 /video-presets -->
<div class="flex flex-wrap items-center gap-1">
<span v-if="m.type === 'video' && (m.durations || []).length >= 2" class="cfg-tag">{{ m.durations[0] }}–{{ m.durations[m.durations.length - 1] }}</span>
<span v-else-if="m.type === 'video' && (m.durations || []).length === 1" class="cfg-tag">{{ m.durations[0] }}</span>
<span v-if="m.type === 'image'" v-for="r in (m.resolutions || [])" :key="'ir'+r" class="cfg-tag">{{ r }}</span>
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 0" class="cfg-tag">首尾帧{{ Math.min(m.max_reference_images, 2) }}</span>
<span v-if="m.reference_mode === 'frame' && m.max_reference_images > 2" class="cfg-tag">参考图{{ refImages(m) }}</span>
<span v-else-if="m.reference_mode && m.reference_mode !== 'none' && m.reference_mode !== 'frame' && m.max_reference_images > 0" class="cfg-tag">参考图{{ refImages(m) }}</span>
<span v-else-if="m.type === 'image' && m.image_to_image" class="cfg-tag">参考图</span>
<template v-if="caps(m)">
<span v-if="caps(m).videos" class="cap-chip cap-media">视频 {{ caps(m).videos }}</span>
<span v-if="caps(m).audios" class="cap-chip cap-media">音频 {{ caps(m).audios }}</span>
<span v-if="caps(m).videos" class="cfg-tag">视频{{ caps(m).videos }}</span>
<span v-if="caps(m).audios" class="cfg-tag">音频{{ caps(m).audios }}</span>
</template>
<!-- Ratios -->
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cap-chip cap-mono">{{ r.replace(':', '×') }}</span>
<span v-for="r in (m.ratios || [])" :key="'rt'+r" class="cfg-tag">{{ r }}</span>
<span v-if="capEmpty(m)" class="text-white/30 text-xs">—</span>
</div>
</td>
<!-- Display weight — higher floats to the top of the dropdown/list -->
<!-- Successful generations to date -->
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
:class="(m.weight || 0) !== 0 ? 'text-white/85' : 'text-white/30'"
title="展示权重(越大越靠前)">
{{ m.weight || 0 }}
</td>
<!-- Successful generations to date, from event_log via /managed-models -->
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
:class="m.generation_count > 0 ? 'text-white/85' : 'text-white/25'">
:class="m.generation_count > 0 ? 'text-white/85 font-medium' : 'text-white/25'">
{{ (m.generation_count || 0).toLocaleString('en-US') }}
</td>
@@ -256,7 +207,7 @@ onMounted(loadModels)
<td class="px-3 py-3.5 align-middle">
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="m.sellable
? 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
? 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/25'
: 'bg-white/[0.05] text-white/40 ring-white/10'"
:title="m.sellable ? '全部档位有价且有启用渠道,对前台可见' : '配齐全部档位价格并绑定启用渠道后自动上架'">
{{ m.sellable ? '已上架' : '未上架' }}
@@ -288,116 +239,47 @@ onMounted(loadModels)
</template>
<style scoped>
/* --- filter pills (mirrors LogsView so the admin shell stays consistent) */
.fp {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.35rem 0.7rem; font-size: 0.72rem;
border-radius: 0.55rem;
color: rgb(255 255 255 / 0.65);
background: rgb(255 255 255 / 0.05);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
.fp-emerald {
background: rgb(16 185 129 / 0.22);
color: rgb(110 231 183);
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
}
.fp-white {
background: rgb(255 255 255 / 0.18);
color: white;
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.35);
/* --- type tag (vivid 同款:图像蓝底 / 视频墨底) */
.kind-tag {
display: inline-flex; padding: 3px 10px; border-radius: 999px;
font-size: 12px; font-weight: 700; white-space: nowrap;
}
.kind-image { background: rgb(14 165 233 / 0.12); color: rgb(2 132 199); }
.kind-video { background: rgb(24 24 27 / 0.1); color: rgb(24 24 27); }
/* --- price chips: tight pill showing a key (resolution or duration) plus
its emerald-tinted price. */
.price-chip {
display: inline-flex;
align-items: center;
padding: 0.18rem 0.55rem;
font-size: 0.7rem;
border-radius: 9999px;
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
white-space: nowrap;
/* --- pricing chip: 档位 + 普通价(绿) + 代理价(琥珀) --- */
.p-chip {
display: inline-flex; align-items: baseline; gap: 6px;
padding: 3px 9px; border-radius: 8px;
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
font-size: 12px; white-space: nowrap;
}
.p-chip b { color: var(--fg); font-weight: 600; }
.p-chip em { font-style: normal; font-weight: 700; color: rgb(5 150 105); font-variant-numeric: tabular-nums; }
.p-chip em.ag { color: rgb(180 83 9); font-weight: 600; }
.p-chip em.ag::before { content: '代理 '; font-size: 10px; font-weight: 500; opacity: 0.75; }
/* --- capability chips: smaller, monochrome variants */
.cap-chip {
/* --- config tags: uniform neutral mono chips (时长 / 分辨率 / 参考 / 比例) --- */
.cfg-tag {
display: inline-flex; align-items: center;
padding: 0.15rem 0.5rem;
font-size: 0.68rem; font-weight: 500;
border-radius: 9999px;
padding: 2px 8px; border-radius: 6px;
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
font-size: 11px; font-weight: 600; color: var(--fg-2);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: nowrap;
}
.cap-dur {
background: rgb(14 165 233 / 0.12);
color: rgb(147 197 253);
box-shadow: inset 0 0 0 1px rgb(129 140 248 / 0.3);
}
.cap-frame {
background: rgb(251 113 133 / 0.12);
color: rgb(244 114 182);
box-shadow: inset 0 0 0 1px rgb(244 114 182 / 0.35);
}
.cap-ref {
background: rgb(16 185 129 / 0.12);
color: rgb(110 231 183);
box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.3);
}
.cap-media {
background: rgb(245 158 11 / 0.12);
color: rgb(252 211 77);
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.35);
}
.cap-slate {
background: rgb(255 255 255 / 0.05);
color: rgb(255 255 255 / 0.7);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
}
.cap-mono {
background: transparent;
color: rgb(255 255 255 / 0.5);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
}
/* --- danger variant for 删除 (kept for any other .btn-soft callers) */
.btn-soft.danger {
color: rgb(253 164 175);
background: rgb(244 63 94 / 0.12);
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
}
.btn-soft.danger:hover {
color: white;
background: rgb(244 63 94 / 0.25);
}
/* --- compact square icon button for the actions column. Predictable
width (1.9rem × 3 + gaps) keeps the row inside its 9rem slot, so
the last button never gets clipped. */
/* --- compact square icon button for the actions column --- */
.act {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 0.5rem;
color: rgb(255 255 255 / 0.7);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem; border-radius: 0.6rem;
color: var(--fg-3); background: var(--surface-solid);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
.act.danger {
color: rgb(253 164 175);
background: rgb(244 63 94 / 0.12);
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
}
.act.danger:hover {
color: white;
background: rgb(244 63 94 / 0.25);
}
.act:hover { background: var(--surface-2); color: var(--fg); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.14); }
.act.danger:hover { background: rgb(244 63 94 / 0.08); color: #dc2626; box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.28); }
</style>
+12 -13
View File
@@ -8,6 +8,7 @@ import { api, jsonBody } from '../api'
import { openPayment } from '../payment'
import { refreshMe } from '../auth'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
const router = useRouter()
const items = ref([])
@@ -34,6 +35,7 @@ function fmt(unix) {
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
const STATUS_OPTS = [{ value: '', label: '全部状态' }, { value: 'pending', label: '待支付' }, { value: 'paid', label: '已支付' }, { value: 'cancelled', label: '已取消' }]
async function load() {
loading.value = true
const qs = new URLSearchParams({ limit: String(pageSize), offset: String((page.value - 1) * pageSize) })
@@ -91,27 +93,24 @@ async function cont(o) {
<template>
<section class="space-y-5 log-page">
<div class="flex items-end justify-between flex-wrap gap-3">
<div class="page-head">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">订单</h1>
<p class="text-sm text-slate-500 mt-1">{{ total }} 笔充值订单 · 未支付可继续支付</p>
<h2 class="page-title">订单</h2>
<p class="page-sub">{{ total }} 笔充值订单 · 未支付可继续支付</p>
</div>
<div class="page-actions">
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
<button @click="router.push('/settings')" class="btn-primary"><Icon name="spark" class="w-4 h-4" /> 去充值</button>
</div>
<!-- Filter bar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1.5">
<button v-for="s in [['','全部'],['pending','待支付'],['paid','已支付'],['cancelled','已取消']]" :key="s[0]"
@click="setStatus(s[0])"
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="status === s[0] ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
</div>
<div class="card toolbar">
<SelectMenu :model-value="status" compact :options="STATUS_OPTS" @update:model-value="setStatus" />
<div class="flex-1 min-w-[180px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
class="field !py-1.5 text-xs" placeholder="搜索 订单号 / 金额 / 方式…" />
class="field" placeholder="搜索 订单号 / 金额 / 方式…" />
</div>
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
<button @click="router.push('/settings')" class="btn-primary ml-auto"><Icon name="spark" class="w-3.5 h-3.5" /> 去充值</button>
</div>
<div v-if="loading && !items.length" class="card text-center text-sm text-slate-400 py-24">加载中…</div>
+299 -373
View File
@@ -113,15 +113,15 @@ const recentLogs = computed(() => logs.value.slice(0, 12))
// ---- formatters ----
function statusLabel(s) { return s === 'healthy' ? '健康' : s === 'warning' ? '告警' : '未配置' }
function statusDot(s) { return s === 'healthy' ? 'bg-emerald-400' : s === 'warning' ? 'bg-amber-400' : 'bg-rose-500' }
function statusDot(s) { return s === 'healthy' ? 'bg-emerald-500' : s === 'warning' ? 'bg-amber-500' : 'bg-rose-500' }
function statusPill(s) {
if (s === 'healthy') return 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/30'
if (s === 'warning') return 'bg-amber-500/10 text-amber-300 ring-amber-400/30'
return 'bg-rose-500/10 text-rose-300 ring-rose-400/30'
if (s === 'healthy') return 'bg-emerald-500/10 text-emerald-600 ring-emerald-500/25'
if (s === 'warning') return 'bg-amber-500/10 text-amber-600 ring-amber-500/25'
return 'bg-rose-500/10 text-rose-600 ring-rose-500/25'
}
function logDot(status) {
if (status === 'success') return 'bg-emerald-400'
if (status === 'pending') return 'bg-amber-400'
if (status === 'success') return 'bg-emerald-500'
if (status === 'pending') return 'bg-amber-500'
return 'bg-rose-500'
}
function fmtMs(ms) {
@@ -144,414 +144,340 @@ onUnmounted(() => clearInterval(timer))
</script>
<template>
<section class="space-y-4">
<!-- ===== Toolbar: overall health + refresh ===== -->
<div class="flex items-center justify-between gap-3">
<span class="inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium ring-1 tabular-nums"
:class="statusPill(overallHealth.tone)">
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span>
{{ overallHealth.label }}
</span>
<button @click="refreshAll" class="btn-ghost">刷新</button>
<section class="ov">
<!-- ===== 顶部:健康状态 + 刷新 ===== -->
<div class="ov-bar">
<div>
<h2 class="ov-title">运营概览</h2>
<p class="ov-sub">数据每 10 秒自动刷新</p>
</div>
<!-- ===== KPI strip ===== -->
<div class="grid grid-cols-2 lg:grid-cols-6 gap-3">
<!-- 用户 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">用户</span>
<span class="w-7 h-7 rounded-lg bg-sky-500/15 text-sky-300 grid place-items-center ring-1 ring-sky-400/20">
<Icon name="accounts" class="w-3.5 h-3.5" />
<div class="ov-bar__right">
<span class="pill" :class="'pill--' + overallHealth.tone">
<span class="dot" :class="'dot--' + overallHealth.tone"></span>{{ overallHealth.label }}
</span>
</div>
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtInt(userStats.total) }}</div>
<div class="text-[11px] text-white/45 mt-1">{{ userStats.active }} 活跃 · {{ userStats.admins }} 管理员</div>
<div class="text-[11px] text-emerald-300/80 mt-0.5">
今日新增 <span class="tabular-nums font-medium">{{ fmtInt(userStats.new_24h) }}</span>
· 7日 <span class="tabular-nums font-medium">{{ fmtInt(userStats.new_7d) }}</span>
<button @click="refreshAll" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
</div>
<!-- 今日(零点起 · Asia/Shanghai) -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">今日生成</span>
<span class="w-7 h-7 rounded-lg bg-rose-500/15 text-rose-300 grid place-items-center ring-1 ring-rose-400/20">
<Icon name="spark" class="w-3.5 h-3.5" />
</span>
<!-- ===== KPI 带:一张卡四列 ===== -->
<div class="panel kpis">
<div class="kpi">
<div class="kpi-label">今日生成</div>
<div class="kpi-num">{{ fmtInt(today.total) }}</div>
<div class="kpi-meta">
<span class="ok">{{ today.success }} 成功</span>
<span v-if="today.failed" class="bad">{{ today.failed }} 失败</span>
<span v-if="today.pending" class="warn">{{ today.pending }} 进行中</span>
</div>
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtInt(today.total) }}</div>
<div class="text-[11px] mt-1 flex flex-wrap gap-x-2">
<span class="text-emerald-300 tabular-nums">{{ today.success }} 成功</span>
<span v-if="today.failed" class="text-rose-300 tabular-nums">{{ today.failed }} 失败</span>
<span v-if="today.pending" class="text-amber-300 tabular-nums">{{ today.pending }} 进行中</span>
<div class="kpi-foot">消耗 {{ fmtCredits(today.spent) }} 积分 · {{ fmtInt(todayDau) }} 人活跃</div>
</div>
<div class="text-[11px] text-amber-300 mt-1 tabular-nums">
消耗 {{ fmtCredits(today.spent) }} 积分 · {{ fmtInt(todayDau) }} 活跃
</div>
</div>
<!-- 24h -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">近 24 小时生成</span>
<span class="w-7 h-7 rounded-lg bg-orange-500/15 text-orange-300 grid place-items-center ring-1 ring-orange-400/20">
<Icon name="spark" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-2xl font-semibold tabular-nums mt-2 flex items-baseline gap-2">
<span>{{ fmtInt(day.total) }}</span>
<span v-if="dayDelta.pct != null"
class="text-[11px] font-medium tabular-nums"
:class="dayDelta.dir === 'up' ? 'text-emerald-300' : dayDelta.dir === 'down' ? 'text-rose-300' : 'text-white/45'">
<div class="kpi">
<div class="kpi-label">近 24 小时</div>
<div class="kpi-num">
{{ fmtInt(day.total) }}
<span v-if="dayDelta.pct != null" class="delta" :class="'delta--' + dayDelta.dir">
{{ dayDelta.dir === 'up' ? '↑' : dayDelta.dir === 'down' ? '↓' : '·' }}{{ Math.abs(dayDelta.pct) }}%
</span>
</div>
<div class="text-[11px] mt-1 flex flex-wrap gap-x-2">
<span class="text-emerald-300 tabular-nums">{{ day.success }} 成功</span>
<span v-if="day.failed" class="text-rose-300 tabular-nums">{{ day.failed }} 失败</span>
<span v-if="day.pending" class="text-amber-300 tabular-nums">{{ day.pending }} 进行中</span>
<div class="kpi-meta">
<span class="ok">{{ day.success }} 成功</span>
<span v-if="day.failed" class="bad">{{ day.failed }} 失败</span>
<span class="mute">成功率 {{ successRate }}%</span>
</div>
<div v-if="day.total" class="text-[10px] text-white/40 mt-1 tabular-nums">
Web {{ day.web }} · API {{ day.api }} · 图 {{ day.image }} · 视 {{ day.video }}
<div class="kpi-foot">Web {{ day.web }} · API {{ day.api }} · 图 {{ day.image }} · 视 {{ day.video }}</div>
</div>
<div class="kpi">
<div class="kpi-label">累计生成</div>
<div class="kpi-num">{{ fmtInt(lifetime.total || 0) }}</div>
<div class="kpi-meta">
<span class="ok">{{ lifetime.success || 0 }} 成功</span>
<span v-if="lifetime.failed" class="bad">{{ lifetime.failed }} 失败</span>
</div>
<div class="kpi-foot">API {{ lifetime.api || 0 }} · 图 {{ lifetime.image || 0 }} · 视 {{ lifetime.video || 0 }}</div>
</div>
<div class="kpi">
<div class="kpi-label">平均耗时 · 24h</div>
<div class="kpi-num">{{ fmtMs(avg24hMs) }}</div>
<div class="kpi-meta"><span class="mute">产物存储 {{ fmtSize(stats.generated_size_bytes || 0) }}</span></div>
<div class="kpi-foot">{{ fmtInt(stats.generated_count) }} 个文件</div>
</div>
</div>
<!-- 累计生成(全部 · 持久计数,不随日志清理变化) -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">累计生成</span>
<span class="w-7 h-7 rounded-lg bg-sky-500/15 text-sky-300 grid place-items-center ring-1 ring-sky-400/20">
<Icon name="overview" class="w-3.5 h-3.5" />
</span>
<!-- ===== 资源条:用户 / 渠道 / 模型 / 积分 ===== -->
<div class="strip">
<div class="chip">
<span class="chip-ic"><Icon name="accounts" :size="16" /></span>
<div><div class="chip-num">{{ fmtInt(userStats.total) }}</div>
<div class="chip-label">用户 · {{ userStats.active }} 活跃 · 今日 +{{ fmtInt(userStats.new_24h) }}</div></div>
</div>
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtInt(lifetime.total || 0) }}</div>
<div class="text-[11px] mt-1 flex flex-wrap gap-x-2">
<span class="text-emerald-300 tabular-nums">{{ lifetime.success || 0 }} 成功</span>
<span v-if="lifetime.failed" class="text-rose-300 tabular-nums">{{ lifetime.failed }} 失败</span>
<div class="chip">
<span class="chip-ic"><Icon name="plug" :size="16" /></span>
<div><div class="chip-num">{{ tokens.active }}<small> / {{ tokens.total }}</small></div>
<div class="chip-label">渠道 · 启用 / 已配置</div></div>
</div>
<div class="text-[10px] text-white/40 mt-1 tabular-nums">
API {{ lifetime.api || 0 }} · 图 {{ lifetime.image || 0 }} · 视 {{ lifetime.video || 0 }}
<div class="chip">
<span class="chip-ic"><Icon name="models" :size="16" /></span>
<div><div class="chip-num">{{ modelTypes.total }}</div>
<div class="chip-label">模型 · 图像 {{ modelTypes.image }} · 视频 {{ modelTypes.video }}</div></div>
</div>
<div class="chip">
<span class="chip-ic"><Icon name="receipt" :size="16" /></span>
<div><div class="chip-num">{{ fmtCredits(userStats.credits_total) }}</div>
<div class="chip-label">系统积分总和 · 24h 消耗 {{ fmtCredits(day.spent) }}</div></div>
</div>
</div>
<!-- 平均耗时 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">平均耗时</span>
<span class="w-7 h-7 rounded-lg bg-emerald-500/15 text-emerald-300 grid place-items-center ring-1 ring-emerald-400/20">
<Icon name="refresh" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtMs(avg24hMs) }}</div>
<div class="text-[11px] text-white/45 mt-1">{{ successRate }}% 成功率 · 24h</div>
</div>
<!-- 存储 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">产物存储</span>
<span class="w-7 h-7 rounded-lg bg-amber-500/15 text-amber-300 grid place-items-center ring-1 ring-amber-400/20">
<Icon name="files" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-2xl font-semibold tabular-nums mt-2">{{ fmtSize(stats.generated_size_bytes || 0) }}</div>
<div class="text-[11px] text-white/45 mt-1">{{ fmtInt(stats.generated_count) }} 个文件</div>
<!-- ===== 趋势(宽)+ 渠道健康 ===== -->
<div class="split">
<div class="panel">
<div class="panel-h">
<h3>24 小时生成趋势</h3>
<div class="legend">
<span><i class="sw sw--ink"></i>图像</span>
<span><i class="sw sw--soft"></i>视频</span>
<span class="tnum">峰值 {{ hourMax }}/h</span>
</div>
</div>
<!-- ===== Secondary stat row ===== -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="card p-4">
<div class="text-xs text-white/55">系统积分总和</div>
<div class="text-xl font-semibold tabular-nums mt-2">{{ fmtCredits(userStats.credits_total) }}</div>
<div class="text-[11px] text-white/45 mt-1">
所有用户余额累加 · <span class="text-amber-300">24h 消耗 {{ fmtCredits(day.spent) }}</span>
</div>
</div>
<div class="card p-4">
<div class="text-xs text-white/55">渠道</div>
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
<span class="text-emerald-300">{{ tokens.active }}</span>
<span class="text-white/30">/</span>
<span>{{ tokens.total }}</span>
</div>
<div class="text-[11px] text-white/45 mt-1">启用 / 已配置</div>
</div>
<div class="card p-4">
<div class="text-xs text-white/55">模型</div>
<div class="text-xl font-semibold tabular-nums mt-2">{{ modelTypes.total }}</div>
<div class="text-[11px] text-white/45 mt-1">图像 {{ modelTypes.image }} · 视频 {{ modelTypes.video }}</div>
</div>
<div class="card p-4">
<div class="text-xs text-white/55">活跃用户 · 24h</div>
<div class="text-xl font-semibold tabular-nums mt-2">{{ fmtInt(dau) }}</div>
<div class="text-[11px] text-white/45 mt-1">近 3 天累计生成 {{ fmtInt(week.total) }}</div>
</div>
</div>
<!-- ===== Operations row: CDK / 邀请 / 签到 ===== -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<!-- 兑换码 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">兑换码 CDK</span>
<span class="w-7 h-7 rounded-lg bg-sky-500/15 text-sky-300 grid place-items-center ring-1 ring-sky-400/20">
<Icon name="spark" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
<span class="text-emerald-300">{{ fmtInt(cdk.active) }}</span>
<span class="text-white/30 text-sm">未用 /</span>
<span class="text-white/70 text-base">{{ fmtInt(cdk.redeemed) }} 已兑</span>
</div>
<div class="text-[11px] text-white/45 mt-1">
待兑积分 <span class="text-amber-300 tabular-nums">{{ fmtCredits(cdk.active_amount) }}</span>
· 已发出 <span class="tabular-nums">{{ fmtCredits(cdk.redeemed_amount) }}</span>
</div>
</div>
<!-- 邀请 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">邀请</span>
<span class="w-7 h-7 rounded-lg bg-rose-500/15 text-rose-300 grid place-items-center ring-1 ring-rose-400/20">
<Icon name="accounts" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
<span>{{ fmtInt(invites.total) }}</span>
<span class="text-white/40 text-sm">邀请注册</span>
</div>
<div class="text-[11px] text-white/45 mt-1">
<span class="text-emerald-300 tabular-nums">{{ fmtInt(invites.completed) }}</span> 已达成奖励
· 已发 <span class="text-amber-300 tabular-nums">{{ fmtCredits(invites.reward_paid) }}</span> 积分
</div>
</div>
<!-- 签到 -->
<div class="card p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/55">今日签到</span>
<span class="w-7 h-7 rounded-lg bg-teal-500/15 text-teal-300 grid place-items-center ring-1 ring-teal-400/20">
<Icon name="refresh" class="w-3.5 h-3.5" />
</span>
</div>
<div class="text-xl font-semibold tabular-nums mt-2 flex items-baseline gap-1">
<span>{{ fmtInt(checkin.today) }}</span>
<span class="text-white/40 text-sm">人</span>
</div>
<div class="text-[11px] text-white/45 mt-1">
发放 <span class="text-amber-300 tabular-nums">{{ fmtCredits(checkin.awarded_today) }}</span> 积分
</div>
</div>
</div>
<!-- ===== Provider health + 24h trend ===== -->
<div class="grid lg:grid-cols-2 gap-3">
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">渠道健康</h2>
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1"
:class="statusPill(overallHealth.tone)">
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(overallHealth.tone)"></span>
{{ overallHealth.label }}
</span>
</div>
<div class="p-3">
<div v-if="!providerHealth.length" class="text-center text-xs text-white/40 py-8">未配置渠道</div>
<div v-else class="space-y-0.5">
<div v-for="p in providerHealth" :key="p.id"
class="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/[0.04] transition-colors">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ p.name }}</div>
<div class="text-[11px] text-white/45 mt-0.5 truncate">
{{ p.model_count }} 模型 · 成功 {{ p.success_total || 0 }}
<span v-if="p.upstream_balance != null"> · 余额 {{ p.upstream_balance }}</span>
</div>
</div>
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 tabular-nums"
:class="statusPill(p.status)">
<span class="w-1.5 h-1.5 rounded-full" :class="statusDot(p.status)"></span>
{{ statusLabel(p.status) }}
</span>
</div>
</div>
</div>
</div>
<div class="card flex flex-col">
<div class="px-5 py-3 border-b border-white/[0.06] flex items-baseline justify-between">
<h2 class="text-sm font-semibold">24 小时生成趋势</h2>
<div class="text-[11px] text-white/45 flex items-center gap-3">
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-sky-400/80"></span>图像</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-sm bg-rose-400/80"></span>视频</span>
<span class="tabular-nums">峰值 {{ hourMax }}/h</span>
</div>
</div>
<div class="p-5 flex-1 flex flex-col">
<div class="flex items-end gap-[3px] flex-1 min-h-[8rem]">
<div v-for="(b, i) in hourBuckets" :key="i"
class="group/bar relative flex-1 flex flex-col justify-end rounded-t overflow-visible"
<div class="trend">
<div class="trend-bars">
<div v-for="(b, i) in hourBuckets" :key="i" class="tb"
:style="{ height: Math.max(4, ((b.image + b.video) / hourMax) * 100) + '%' }">
<!-- hover tooltip -->
<div class="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 z-10 hidden group-hover/bar:block
whitespace-nowrap rounded-md bg-black/90 ring-1 ring-white/10 px-2 py-1 text-[10px] text-white/90 tabular-nums">
{{ 23 - i }}h 前 · 图 {{ b.image }} / 视 {{ b.video }}
</div>
<div v-if="b.video" class="bg-rose-400/80 group-hover/bar:bg-rose-400" :style="{ flex: b.video }"></div>
<div v-if="b.image" class="bg-sky-400/80 group-hover/bar:bg-sky-400" :style="{ flex: b.image }"></div>
<div v-if="!b.image && !b.video" class="bg-white/[0.06] group-hover/bar:bg-white/15 flex-1 rounded-t"></div>
<div class="tip">{{ 23 - i }}h 前 · 图 {{ b.image }} / 视 {{ b.video }}</div>
<div v-if="b.video" class="tb-v" :style="{ flex: b.video }"></div>
<div v-if="b.image" class="tb-i" :style="{ flex: b.image }"></div>
<div v-if="!b.image && !b.video" class="tb-0"></div>
</div>
</div>
<div class="flex justify-between text-[10px] text-white/40 mt-2 tabular-nums">
<span>-24h</span><span>-18h</span><span>-12h</span><span>-6h</span><span>现在</span>
<div class="trend-x"><span>-24h</span><span>-18h</span><span>-12h</span><span>-6h</span><span>现在</span></div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h3>渠道健康</h3></div>
<div class="list">
<div v-if="!providerHealth.length" class="empty">未配置渠道</div>
<div v-for="p in providerHealth" :key="p.id" class="row">
<div class="row-main">
<div class="row-name">{{ p.name }}</div>
<div class="row-sub">{{ p.model_count }} 模型 · 成功 {{ p.success_total || 0 }}<span v-if="p.upstream_balance != null"> · 余额 {{ p.upstream_balance }}</span></div>
</div>
<span class="pill" :class="'pill--' + p.status"><span class="dot" :class="'dot--' + p.status"></span>{{ statusLabel(p.status) }}</span>
</div>
</div>
</div>
</div>
<!-- ===== Analytics (range-toggled): top models / failures / spenders ===== -->
<div class="flex items-center justify-between gap-3 pt-1">
<h2 class="text-sm font-semibold text-white/80">使用分析 · <span class="text-white/45 font-normal">{{ rangeLabel }}</span></h2>
<div class="inline-flex rounded-lg bg-white/[0.04] ring-1 ring-white/[0.08] p-0.5 text-xs">
<button @click="range = 'day'"
class="px-3 py-1 rounded-md transition-colors"
:class="range === 'day' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'">
近 24h
</button>
<button @click="range = 'week'"
class="px-3 py-1 rounded-md transition-colors"
:class="range === 'week' ? 'bg-white/10 text-white font-medium' : 'text-white/50 hover:text-white/80'">
近 3d
</button>
<!-- ===== 运营:CDK / 邀请 / 签到 ===== -->
<div class="trio">
<div class="panel ops">
<div class="ops-label">兑换码 CDK</div>
<div class="ops-num"><span class="ok">{{ fmtInt(cdk.active) }}</span><small>未用</small> <span class="sep">/</span> {{ fmtInt(cdk.redeemed) }}<small>已兑</small></div>
<div class="ops-foot">待兑 {{ fmtCredits(cdk.active_amount) }} 积分 · 已发出 {{ fmtCredits(cdk.redeemed_amount) }}</div>
</div>
<div class="panel ops">
<div class="ops-label">邀请</div>
<div class="ops-num">{{ fmtInt(invites.total) }}<small>邀请注册</small></div>
<div class="ops-foot">{{ fmtInt(invites.completed) }} 已达成奖励 · 已发 {{ fmtCredits(invites.reward_paid) }} 积分</div>
</div>
<div class="panel ops">
<div class="ops-label">今日签到</div>
<div class="ops-num">{{ fmtInt(checkin.today) }}<small>人</small></div>
<div class="ops-foot">发放 {{ fmtCredits(checkin.awarded_today) }} 积分</div>
</div>
</div>
<div class="grid lg:grid-cols-2 gap-3">
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06]">
<h2 class="text-sm font-semibold">热门模型</h2>
</div>
<div class="p-5">
<div v-if="!modelUsage.length" class="text-center text-xs text-white/40 py-6">尚无生成记录</div>
<div v-else class="space-y-3">
<div v-for="m in modelUsage" :key="m.model" class="text-sm">
<div class="flex items-baseline justify-between gap-3 mb-1">
<span class="font-mono text-[12px] text-white/85 truncate">{{ m.model }}</span>
<span class="flex items-baseline gap-2 shrink-0">
<span v-if="m.avg_ms" class="text-[10px] text-white/40 tabular-nums">{{ fmtMs(m.avg_ms) }}</span>
<span class="tabular-nums text-xs font-semibold">{{ m.count }}</span>
</span>
</div>
<div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
<div class="h-full rounded-full bg-gradient-to-r from-orange-400 to-rose-500"
:style="{ width: ((m.count / usageMax) * 100) + '%' }"></div>
<!-- ===== 使用分析 ===== -->
<div class="ov-bar ov-bar--sub">
<h2 class="ov-title ov-title--sm">使用分析 <span class="mute">· {{ rangeLabel }}</span></h2>
<div class="seg">
<button :class="{ on: range === 'day' }" @click="range = 'day'">近 24h</button>
<button :class="{ on: range === 'week' }" @click="range = 'week'">近 3d</button>
</div>
</div>
<div class="split split--even">
<div class="panel">
<div class="panel-h"><h3>热门模型</h3></div>
<div class="list list--pad">
<div v-if="!modelUsage.length" class="empty">尚无生成记录</div>
<div v-for="m in modelUsage" :key="m.model" class="bar-row">
<div class="bar-top">
<span class="mono">{{ m.model }}</span>
<span class="bar-right"><em v-if="m.avg_ms">{{ fmtMs(m.avg_ms) }}</em><b>{{ m.count }}</b></span>
</div>
<div class="bar"><i :style="{ width: ((m.count / usageMax) * 100) + '%' }"></i></div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h3>失败原因 Top</h3></div>
<div class="list list--pad">
<div v-if="!failures.length" class="empty">{{ rangeLabel }}内没有失败,一切正常</div>
<div v-for="f in failures" :key="f.reason" class="fail">
<span class="dot dot--down"></span>
<span class="fail-r">{{ f.reason }}</span>
<b class="bad">×{{ f.count }}</b>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h3>用户消耗 Top</h3></div>
<div class="list list--pad">
<div v-if="!topUsers.length" class="empty">{{ rangeLabel }}内无消耗记录</div>
<div v-for="u in topUsers" :key="u.user_id || u.name" class="bar-row">
<div class="bar-top">
<span>{{ u.name }}</span>
<span class="bar-right"><em>{{ u.count }} 次</em><b>{{ fmtCredits(u.spent) }}</b></span>
</div>
<div class="bar"><i :style="{ width: ((u.spent / topUserMax) * 100) + '%' }"></i></div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h3>积分消耗概览</h3></div>
<div class="mini">
<div><div class="mini-l">近 24 小时</div><div class="mini-n">{{ fmtCredits(day.spent) }}</div><div class="mini-s">{{ day.success }} 次成功生成</div></div>
<div><div class="mini-l">近 3 天</div><div class="mini-n">{{ fmtCredits(week.spent) }}</div><div class="mini-s">{{ week.success }} 次成功生成</div></div>
<div><div class="mini-l">单次均价</div><div class="mini-n">{{ day.success ? fmtCredits(Math.round(day.spent / day.success)) : '—' }}</div><div class="mini-s">24h 平均</div></div>
<div><div class="mini-l">消耗用户数</div><div class="mini-n">{{ topUsers.length }}</div><div class="mini-s">{{ rangeLabel }}有消耗</div></div>
</div>
</div>
</div>
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06]">
<h2 class="text-sm font-semibold">失败原因 Top</h2>
</div>
<div class="p-5">
<div v-if="!failures.length" class="text-center text-xs text-white/40 py-6">{{ rangeLabel }}内没有失败 — 一切正常</div>
<div v-else class="space-y-2">
<div v-for="f in failures" :key="f.reason"
class="flex items-start gap-3 px-2 py-2 rounded-lg hover:bg-white/[0.04]">
<span class="w-1.5 h-1.5 mt-1.5 rounded-full bg-rose-400 shrink-0"></span>
<div class="flex-1 min-w-0">
<div class="text-xs text-white/85 break-all leading-snug">{{ f.reason }}</div>
</div>
<span class="tabular-nums text-xs font-semibold text-rose-300 shrink-0">×{{ f.count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- ===== User consumption + spend summary ===== -->
<div class="grid lg:grid-cols-2 gap-3">
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06]">
<h2 class="text-sm font-semibold">用户消耗 Top · {{ rangeLabel }}</h2>
</div>
<div class="p-5">
<div v-if="!topUsers.length" class="text-center text-xs text-white/40 py-6">{{ rangeLabel }}内无消耗记录</div>
<div v-else class="space-y-3">
<div v-for="u in topUsers" :key="u.user_id || u.name" class="text-sm">
<div class="flex items-baseline justify-between gap-3 mb-1">
<span class="text-[12px] text-white/85 truncate">{{ u.name }}</span>
<span class="flex items-baseline gap-2 shrink-0">
<span class="text-[10px] text-white/40 tabular-nums">{{ u.count }} 次</span>
<span class="tabular-nums text-xs font-semibold text-amber-300">{{ fmtCredits(u.spent) }}</span>
</span>
</div>
<div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
<div class="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500"
:style="{ width: ((u.spent / topUserMax) * 100) + '%' }"></div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06]">
<h2 class="text-sm font-semibold">积分消耗概览</h2>
</div>
<div class="p-5 grid grid-cols-2 gap-4">
<div>
<div class="text-xs text-white/55">近 24 小时</div>
<div class="text-2xl font-semibold tabular-nums mt-1 text-amber-300">{{ fmtCredits(day.spent) }}</div>
<div class="text-[11px] text-white/40 mt-1">{{ day.success }} 次成功生成</div>
</div>
<div>
<div class="text-xs text-white/55">近 3 天</div>
<div class="text-2xl font-semibold tabular-nums mt-1 text-amber-300">{{ fmtCredits(week.spent) }}</div>
<div class="text-[11px] text-white/40 mt-1">{{ week.success }} 次成功生成</div>
</div>
<div>
<div class="text-xs text-white/55">单次均价</div>
<div class="text-xl font-semibold tabular-nums mt-1">{{ day.success ? fmtCredits(Math.round(day.spent / day.success)) : '—' }}</div>
<div class="text-[11px] text-white/40 mt-1">24h 平均</div>
</div>
<div>
<div class="text-xs text-white/55">消耗用户数</div>
<div class="text-xl font-semibold tabular-nums mt-1">{{ topUsers.length }}</div>
<div class="text-[11px] text-white/40 mt-1">{{ rangeLabel }}有消耗</div>
</div>
</div>
</div>
</div>
<!-- ===== Recent activity (full width) ===== -->
<div class="card">
<div class="px-5 py-3 border-b border-white/[0.06] flex items-baseline justify-between">
<h2 class="text-sm font-semibold">最近活动</h2>
<router-link to="/admin/logs" class="text-[11px] text-white/55 hover:text-white">查看全部 →</router-link>
</div>
<div class="p-3">
<div v-if="!recentLogs.length" class="text-center text-xs text-white/40 py-6">尚无活动</div>
<div v-else>
<div v-for="e in recentLogs" :key="e.id"
class="flex items-center gap-3 px-2 py-2 text-xs rounded-lg hover:bg-white/[0.03]">
<span class="w-1.5 h-1.5 rounded-full shrink-0" :class="logDot(e.status)"></span>
<span class="text-[10px] uppercase tracking-wider font-medium w-9"
:class="e.kind === 'video' ? 'text-rose-300' : 'text-sky-300'">
{{ e.kind === 'video' ? '视频' : '图像' }}
</span>
<span class="font-mono text-white/85 truncate w-40 shrink-0">{{ e.model }}</span>
<span class="text-white/55 truncate flex-1 min-w-0">{{ e.prompt }}</span>
<span class="text-white/40 tabular-nums whitespace-nowrap w-12 text-right">{{ fmtMs(e.elapsed_ms) }}</span>
<span class="text-white/40 whitespace-nowrap text-right tabular-nums">{{ fmtTs(e.ts) }}</span>
<!-- ===== 最近活动 ===== -->
<div class="panel">
<div class="panel-h">
<h3>最近活动</h3>
<router-link to="/admin/logs" class="more">查看全部 →</router-link>
</div>
<div class="list">
<div v-if="!recentLogs.length" class="empty">尚无活动</div>
<div v-for="e in recentLogs" :key="e.id" class="ov-act">
<span class="dot" :class="e.status === 'success' ? 'dot--healthy' : e.status === 'pending' ? 'dot--warning' : 'dot--down'"></span>
<span class="ov-act-kind">{{ e.kind === 'video' ? '视频' : '图像' }}</span>
<span class="mono ov-act-model">{{ e.model }}</span>
<span class="ov-act-prompt">{{ e.prompt }}</span>
<span class="ov-act-ms tnum">{{ fmtMs(e.elapsed_ms) }}</span>
<span class="ov-act-ts tnum">{{ fmtTs(e.ts) }}</span>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.ov { display: flex; flex-direction: column; gap: 16px; color: var(--fg); }
.tnum { font-variant-numeric: tabular-nums; }
.mono { font-family: ui-monospace, 'SF Mono', Consolas, monospace; font-size: 12px; }
.mute { color: var(--fg-3); font-weight: 500; }
.ok { color: #15803d; } .bad { color: #dc2626; } .warn { color: #b45309; }
.ov-bar { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }
.ov-bar--sub { margin-top: 8px; align-items: center; }
.ov-bar__right { display: flex; align-items: center; gap: 10px; }
.ov-title { font-size: 20px; font-weight: 800; letter-spacing: -0.03em; color: var(--fg); }
.ov-title--sm { font-size: 16px; }
.ov-sub { margin-top: 2px; font-size: 12px; color: var(--fg-3); }
.ov-btn { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px; border-radius: 999px; border: 1px solid rgb(24 24 27 / 0.12); background: var(--surface-solid); font-size: 13px; font-weight: 600; color: var(--fg); cursor: pointer; transition: border-color .15s, box-shadow .15s; }
.ov-btn:hover { border-color: rgb(24 24 27 / 0.3); box-shadow: 0 4px 12px rgb(24 24 27 / 0.08); }
.panel { background: var(--surface-solid); border: 1px solid rgb(24 24 27 / 0.07); border-radius: 18px; box-shadow: 0 1px 2px rgb(24 24 27 / 0.03), 0 10px 28px -16px rgb(24 24 27 / 0.12); }
.panel-h { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px 0; }
.panel-h h3 { font-size: 14px; font-weight: 800; color: var(--fg); letter-spacing: -0.01em; }
.more { font-size: 12px; font-weight: 600; color: var(--fg-3); text-decoration: none; }
.more:hover { color: var(--fg); }
.pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 999px; font-size: 12px; font-weight: 700; background: #f4f4f5; color: var(--fg-2); }
.pill--healthy { background: rgb(21 128 61 / 0.1); color: #15803d; }
.pill--warning { background: rgb(180 83 9 / 0.1); color: #b45309; }
.pill--down { background: rgb(220 38 38 / 0.1); color: #dc2626; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: #a1a1aa; flex-shrink: 0; }
.dot--healthy { background: #22c55e; } .dot--warning { background: #f59e0b; } .dot--down { background: #ef4444; }
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); }
.kpi { padding: 22px 24px; }
.kpi + .kpi { border-left: 1px solid rgb(24 24 27 / 0.07); }
.kpi-label { font-size: 12px; font-weight: 700; color: var(--fg-3); letter-spacing: .02em; }
.kpi-num { margin-top: 10px; font-size: 34px; font-weight: 800; letter-spacing: -0.04em; line-height: 1; color: var(--fg); font-variant-numeric: tabular-nums; display: flex; align-items: baseline; gap: 8px; }
.kpi-meta { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 12px; font-weight: 700; }
.kpi-foot { margin-top: 8px; font-size: 11.5px; color: var(--fg-3); }
.delta { font-size: 12px; font-weight: 700; }
.delta--up { color: #15803d; } .delta--down { color: #dc2626; } .delta--flat { color: var(--fg-3); }
.strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.chip { display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: var(--surface-solid); border: 1px solid rgb(24 24 27 / 0.07); border-radius: 16px; }
.chip-ic { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; background: #f4f4f5; color: #18181b; flex-shrink: 0; }
.chip-num { font-size: 20px; font-weight: 800; letter-spacing: -0.03em; color: var(--fg); font-variant-numeric: tabular-nums; line-height: 1.1; }
.chip-num small { font-size: 13px; font-weight: 600; color: var(--fg-3); }
.chip-label { margin-top: 3px; font-size: 11.5px; color: var(--fg-3); }
.split { display: grid; grid-template-columns: 1.7fr 1fr; gap: 16px; }
.split--even { grid-template-columns: 1fr 1fr; }
.legend { display: flex; align-items: center; gap: 14px; font-size: 11.5px; color: var(--fg-3); }
.legend span { display: flex; align-items: center; gap: 5px; }
.sw { width: 9px; height: 9px; border-radius: 3px; display: inline-block; }
.sw--ink { background: #18181b; } .sw--soft { background: #d4d4d8; }
.trend { padding: 18px 20px 16px; display: flex; flex-direction: column; height: calc(100% - 44px); min-height: 220px; }
.trend-bars { flex: 1; display: flex; align-items: flex-end; gap: 4px; min-height: 150px; }
.tb { position: relative; flex: 1; display: flex; flex-direction: column; justify-content: flex-end; border-radius: 4px 4px 0 0; overflow: visible; }
.tb-i { background: #18181b; border-radius: 3px 3px 0 0; } .tb-v { background: #d4d4d8; }
.tb-0 { flex: 1; background: #f1f1f3; border-radius: 3px 3px 0 0; }
.tb:hover .tb-i { background: #3f3f46; }
.tip { display: none; position: absolute; top: -34px; left: 50%; transform: translateX(-50%); white-space: nowrap; z-index: 5; background: #18181b; color: #fff; font-size: 10.5px; padding: 4px 8px; border-radius: 6px; font-variant-numeric: tabular-nums; }
.tb:hover .tip { display: block; }
.trend-x { display: flex; justify-content: space-between; margin-top: 8px; font-size: 10.5px; color: var(--fg-3); }
.list { padding: 8px 12px 12px; }
.list--pad { padding: 14px 20px 18px; display: flex; flex-direction: column; gap: 14px; }
.empty { padding: 28px 0; text-align: center; font-size: 12.5px; color: var(--fg-3); }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 8px; border-radius: 12px; }
.row:hover { background: #f7f7f8; }
.row-main { flex: 1; min-width: 0; }
.row-name { font-size: 13.5px; font-weight: 700; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-sub { margin-top: 2px; font-size: 11.5px; color: var(--fg-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trio { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.ops { padding: 18px 20px; }
.ops-label { font-size: 12px; font-weight: 700; color: var(--fg-3); }
.ops-num { margin-top: 8px; font-size: 24px; font-weight: 800; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; color: var(--fg); }
.ops-num small { margin-left: 4px; font-size: 12px; font-weight: 600; color: var(--fg-3); letter-spacing: 0; }
.ops-num .sep { color: #d4d4d8; font-weight: 400; }
.ops-foot { margin-top: 6px; font-size: 11.5px; color: var(--fg-3); }
.seg { display: inline-flex; padding: 3px; border-radius: 999px; background: #f0f0f2; }
.seg button { padding: 5px 14px; border: 0; border-radius: 999px; background: transparent; font-size: 12px; font-weight: 700; color: var(--fg-3); cursor: pointer; transition: all .15s; }
.seg button.on { background: var(--surface-solid); color: var(--fg); box-shadow: 0 1px 3px rgb(24 24 27 / 0.12); }
.bar-top { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 6px; font-size: 13px; color: var(--fg); }
.bar-top > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-right { display: flex; align-items: baseline; gap: 10px; flex-shrink: 0; }
.bar-right em { font-style: normal; font-size: 11px; color: var(--fg-3); font-variant-numeric: tabular-nums; }
.bar-right b { font-size: 13px; font-weight: 800; font-variant-numeric: tabular-nums; }
.bar { height: 6px; border-radius: 999px; background: #f1f1f3; overflow: hidden; }
.bar i { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, #18181b, #52525b); }
.fail { display: flex; align-items: flex-start; gap: 10px; }
.fail .dot { margin-top: 6px; }
.fail-r { flex: 1; min-width: 0; font-size: 12.5px; line-height: 1.5; color: var(--fg); word-break: break-all; }
.mini { display: grid; grid-template-columns: 1fr 1fr; gap: 20px 24px; padding: 18px 20px 22px; }
.mini-l { font-size: 12px; font-weight: 700; color: var(--fg-3); }
.mini-n { margin-top: 4px; font-size: 24px; font-weight: 800; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; color: var(--fg); }
.mini-s { margin-top: 2px; font-size: 11.5px; color: var(--fg-3); }
.ov-act { display: flex; align-items: center; gap: 12px; padding: 9px 8px; border-radius: 10px; font-size: 12.5px; }
.ov-act:hover { background: #f7f7f8; }
.ov-act-kind { width: 32px; font-size: 11px; font-weight: 700; color: var(--fg-3); }
.ov-act-model { width: 170px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); }
.ov-act-prompt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-2); }
.ov-act-ms { width: 52px; text-align: right; color: var(--fg-3); }
.ov-act-ts { color: var(--fg-3); white-space: nowrap; }
@media (max-width: 1100px) {
.kpis, .strip { grid-template-columns: repeat(2, 1fr); }
.kpi:nth-child(3) { border-left: 0; }
.kpi:nth-child(n+3) { border-top: 1px solid rgb(24 24 27 / 0.07); }
.split, .split--even, .trio { grid-template-columns: 1fr; }
}
</style>
+6 -7
View File
@@ -116,9 +116,8 @@ const maxRefs = computed(() => {
const extra = familyPreset.value ? 0 : maxVideos.value + maxAudios.value
return Math.max(a, b) + extra
}
// Image-to-image: honor the model's configured max (gpt-image-2=3,
// seedream-4.5=6, flux-klein-2=4 …). Fall back to 1 when image_to_image is on
// but no count was set.
// Image-to-image: honor the model's configured max (e.g. 3 or 6 refs, set per
// model). Fall back to 1 when image_to_image is on but no count was set.
const m = Number(model.value?.max_reference_images || 0)
if (m > 0) return m
return model.value?.image_to_image ? 1 : 0
@@ -831,15 +830,15 @@ onUnmounted(() => {
so several generations can run at once (concurrent). -->
<div class="card p-5 space-y-5 lg:sticky lg:top-24 self-start">
<!-- mode switch -->
<div class="grid grid-cols-2 gap-2 p-1 bg-slate-100 rounded-xl">
<div class="mode-switch grid grid-cols-2 gap-2 p-1 bg-slate-100 rounded-xl">
<button @click="setMode('image')" type="button"
class="rounded-lg py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed"
:class="mode === 'image' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
:class="mode === 'image' ? 'mode-on bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
<Icon name="files" class="w-4 h-4 inline -mt-0.5" /> 生图
</button>
<button @click="setMode('video')" type="button"
class="rounded-lg py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed"
:class="mode === 'video' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
:class="mode === 'video' ? 'mode-on bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
<Icon name="video" class="w-4 h-4 inline -mt-0.5" /> 生视频
</button>
</div>
@@ -1077,7 +1076,7 @@ onUnmounted(() => {
<div class="pg-cap text-[11px] leading-tight font-medium line-clamp-2 transition-colors"
:class="item.prompt ? 'pointer-events-auto cursor-pointer' : ''"
:title="item.prompt ? '点击复制提示词' : ''" @click.stop="copyPrompt(item)">{{ item.prompt }}</div>
<div class="pg-cap-sub text-[9px] mt-0.5 font-mono truncate">{{ item.model }}<span v-if="item.deai"> · <span class="text-[#a78bfa]">去AI</span></span><span v-if="item.elapsed_ms"> · {{ (item.elapsed_ms / 1000).toFixed(1) }}s</span></div>
<div class="pg-cap-sub text-[9px] mt-0.5 font-mono truncate">{{ item.model }}<span v-if="item.deai"> · <span class="text-[#71717a]">去AI</span></span><span v-if="item.elapsed_ms"> · {{ (item.elapsed_ms / 1000).toFixed(1) }}s</span></div>
</div>
</template>
<!-- pending / running -->
+155 -198
View File
@@ -26,7 +26,7 @@ onMounted(async () => {
}
})
// ---- API Key (REAL: minted + verified server-side; OpenAI-compatible) ----
// ---- API Key (REAL: minted + verified server-side; vivid /v1 protocol) ----
// The full plaintext is returned only once, right after minting. On reload we
// only have the server's masked preview (the plaintext is never stored).
const apiKey = ref('') // full plaintext — present only right after minting
@@ -218,253 +218,185 @@ async function recharge() {
</script>
<template>
<div class="theme-text space-y-12">
<!-- header -->
<header class="flex items-end justify-between flex-wrap gap-4">
<div class="space-y-5">
<div class="page-head">
<div>
<div class="text-[10px] uppercase tracking-[0.3em] text-orange-300/70 font-medium">账户</div>
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">设置</h1>
<p class="text-white/45 mt-2">API Key、密码、积分、登录状态 — 都在这里。</p>
<h2 class="page-title">设置</h2>
<p class="page-sub">API Key、密码、积分、签到 — 账户全在这里。</p>
</div>
</div>
<button @click="logout"
class="inline-flex items-center gap-2 rounded-full bg-rose-500/15 text-rose-300 hover:bg-rose-500/25 hover:text-rose-200 ring-1 ring-rose-500/30 px-4 py-2 text-sm font-medium transition-all">
<Icon name="close" class="w-3.5 h-3.5" /> 退出登录
</button>
</header>
<!-- ===== Account info ===== -->
<!-- ===== 账户信息 ===== -->
<section class="card p-6">
<div class="flex items-center gap-2 mb-4">
<Icon name="accounts" class="w-4 h-4 text-orange-300" />
<h2 class="text-sm font-semibold">账户信息</h2>
<div class="sec-head">
<span class="sec-ico"><Icon name="accounts" :size="16" /></span>
<h3>账户信息</h3>
</div>
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 text-sm">
<div>
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">用户名</div>
<div class="text-white/90 font-medium truncate" :title="auth.user?.name || ''">{{ auth.user?.name || '—' }}</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-x-6 gap-y-5">
<div class="st-item">
<div class="st-label">用户名</div>
<div class="st-value truncate" :title="auth.user?.name || ''">{{ auth.user?.name || '—' }}</div>
</div>
<div class="col-span-2 lg:col-span-1">
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">绑定邮箱</div>
<div class="text-white/90 font-mono text-xs break-all" :title="auth.user?.email || ''">{{ auth.user?.email || '—' }}</div>
<div class="st-item">
<div class="st-label">绑定邮箱</div>
<div class="st-value break-all" :title="auth.user?.email || ''">{{ auth.user?.email || '—' }}</div>
</div>
<div>
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">角色</div>
<div class="text-white/90">{{ roleLabel }}</div>
<div class="st-item">
<div class="st-label">角色</div>
<div class="st-value">{{ roleLabel }}</div>
</div>
<div>
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">积分余额</div>
<div class="text-amber-300 font-semibold tabular-nums">{{ pointsLabel(balance) }}</div>
<div class="st-item">
<div class="st-label">积分余额</div>
<div class="st-value st-num text-amber-600">{{ pointsLabel(balance) }}</div>
</div>
<div>
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">累计充值</div>
<div class="text-emerald-300 font-semibold tabular-nums">¥{{ rechargeTotal }}</div>
<div class="st-item">
<div class="st-label">累计充值</div>
<div class="st-value st-num text-emerald-600">¥{{ rechargeTotal }}</div>
</div>
<div>
<div class="text-[11px] text-white/40 uppercase tracking-wider mb-1">并发上限</div>
<div class="text-white/90 truncate" :title="concurrencyLabel">{{ concurrencyLabel }}</div>
<div class="st-item">
<div class="st-label">并发上限</div>
<div class="st-value truncate" :title="concurrencyLabel">{{ concurrencyLabel }}</div>
</div>
</div>
</section>
<!-- ===== Two-column grid ===== -->
<div class="grid lg:grid-cols-2 gap-5">
<!-- API KEY (full width) -->
<section class="lg:col-span-2 card overflow-hidden">
<div class="p-7 md:p-8 relative">
<div class="absolute -top-16 -right-16 w-48 h-48 rounded-full opacity-30 blur-3xl"
style="background: radial-gradient(circle,#8b5cf6,transparent 60%)"></div>
<div class="relative grid md:grid-cols-[260px_1fr] gap-8 items-start">
<!-- ===== API Key ===== -->
<section class="card p-6">
<div class="grid md:grid-cols-[220px_1fr] gap-6 items-start">
<div>
<div class="inline-grid w-10 h-10 rounded-xl bg-orange-500/15 ring-1 ring-orange-400/30 grid place-items-center text-orange-300">
<Icon name="plug" class="w-4 h-4" />
<div class="sec-head">
<span class="sec-ico"><Icon name="plug" :size="16" /></span>
<h3>API Key</h3>
</div>
<h2 class="text-xl font-bold mt-4">API Key</h2>
<p class="text-sm text-white/50 mt-2 leading-relaxed">
调用 <code class="bg-white/10 text-white/90 px-1 py-0.5 rounded text-xs">/v1/*</code> 接口需要的访问密钥。完整密钥不保存,仅生成时显示一次。
<p class="text-[13px] text-[color:var(--fg-3)] leading-relaxed mt-1">
调用 <code class="key-code">/v1/*</code> 接口的访问密钥。完整密钥不保存,仅生成时显示一次。
</p>
</div>
<div>
<label class="block text-xs text-white/50 mb-2">当前密钥</label>
<!-- has a key -->
<div v-if="hasKey" class="rounded-xl bg-white/[0.05] ring-1 ring-white/10 px-4 py-3 flex items-center gap-3">
<code class="flex-1 font-mono text-sm text-white/90 break-all">{{ apiKeyRevealed && apiKey ? apiKey : maskedKey }}</code>
<button v-if="apiKey" @click="apiKeyRevealed = !apiKeyRevealed"
class="text-xs rounded-lg px-2.5 py-1.5 ring-1 ring-white/10 hover:bg-white/[0.06] hover:ring-white/20 transition-all whitespace-nowrap">
{{ apiKeyRevealed ? '隐藏' : '显示' }}
</button>
<button @click="copyKey"
:title="apiKey ? '复制完整 Key' : '完整 Key 仅生成时显示一次,已无法找回'"
class="text-xs rounded-lg px-2.5 py-1.5 ring-1 ring-white/10 transition-all hover:bg-white/[0.06] hover:ring-white/20"
:class="!apiKey && 'opacity-40'">
复制
</button>
<div v-if="hasKey" class="key-box">
<code class="flex-1 font-mono text-[13px] text-[color:var(--fg)] break-all">{{ apiKeyRevealed && apiKey ? apiKey : maskedKey }}</code>
<button v-if="apiKey" @click="apiKeyRevealed = !apiKeyRevealed" class="btn-soft !h-8">{{ apiKeyRevealed ? '隐藏' : '显示' }}</button>
<button @click="copyKey" :title="apiKey ? '复制完整 Key' : '完整 Key 仅生成时显示一次,已无法找回'"
class="btn-soft !h-8" :class="!apiKey && 'opacity-40'">复制</button>
</div>
<!-- no key -->
<div v-else class="rounded-xl border border-dashed border-white/15 px-4 py-5 text-center text-xs text-white/40">
<div v-else class="rounded-xl border border-dashed border-[color:var(--hairline)] px-4 py-5 text-center text-xs text-[color:var(--fg-faint)]">
还没有 Key — 点下面的「生成」按钮自动生成
</div>
<p v-if="apiKey" class="text-[11px] text-amber-300/80 mt-2">⚠ 完整 Key 仅显示这一次,请立刻复制保存。</p>
<!-- actions -->
<p v-if="apiKey" class="text-[11px] text-amber-600 mt-2">⚠ 完整 Key 仅显示这一次,请立刻复制保存。</p>
<div class="mt-3 flex gap-2">
<button @click="generateKey"
class="rounded-xl bg-white text-black hover:bg-white/90 px-5 py-2.5 text-sm font-semibold transition-colors">
{{ hasKey ? '重新生成' : '生成 Key' }}
</button>
<button v-if="hasKey" @click="clearKey"
class="rounded-xl ring-1 ring-rose-500/30 text-rose-300 hover:bg-rose-500/15 px-4 py-2.5 text-sm transition-colors">
清除
</button>
<button @click="generateKey" class="btn-primary">{{ hasKey ? '重新生成' : '生成 Key' }}</button>
<button v-if="hasKey" @click="clearKey" class="btn-danger-soft">清除</button>
</div>
<p class="text-[11px] text-white/40 mt-3">
Key 由系统随机生成,不能手动填写。重新生成会让旧 Key 立刻失效。完整调用示例见
<router-link to="/docs" class="text-orange-300 underline">接口文档</router-link>。
<p class="text-[11px] text-[color:var(--fg-faint)] mt-3">
Key 由系统随机生成,不能手动填写。重新生成会让旧 Key 立刻失效。完整调用示例见
<router-link to="/docs" class="underline underline-offset-2">接口文档</router-link>。
</p>
</div>
</div>
</div>
</section>
<!-- CHECK-IN -->
<section v-if="checkinEnabled" class="relative card p-7 md:p-8 overflow-hidden">
<div class="inline-grid w-10 h-10 rounded-xl bg-sky-500/15 ring-1 ring-sky-400/30 grid place-items-center text-sky-300">
<Icon name="refresh" class="w-4 h-4" />
<div class="grid lg:grid-cols-2 gap-5">
<!-- ===== 每日签到 ===== -->
<section v-if="checkinEnabled" class="card p-6">
<div class="sec-head">
<span class="sec-ico"><Icon name="refresh" :size="16" /></span>
<h3>每日签到</h3>
</div>
<h2 class="text-xl font-bold mt-4">每日签到</h2>
<p class="text-sm text-white/50 mt-2">每天签到 +{{ checkinReward }} 积分。</p>
<!-- streak -->
<div class="mt-5 flex items-baseline gap-2">
<span class="text-3xl font-bold tabular-nums">{{ streak }}</span>
<span class="text-xs text-white/40 uppercase tracking-widest">天连续</span>
<p class="text-[13px] text-[color:var(--fg-3)] mt-1">每天签到 +{{ checkinReward }} 积分。</p>
<div class="mt-4 flex items-baseline gap-2">
<span class="text-3xl font-extrabold tabular-nums tracking-tight">{{ streak }}</span>
<span class="text-xs text-[color:var(--fg-3)]">天连续</span>
</div>
<!-- 7-day dots -->
<div class="mt-5 flex items-center gap-1.5">
<div v-for="(d, i) in last7" :key="d.ds"
class="flex-1 h-12 rounded-xl ring-1 transition-all flex flex-col items-center justify-center"
:class="d.lit
? 'bg-sky-500 ring-sky-400 text-white'
: d.isToday
? (checkedToday ? 'bg-sky-500 ring-sky-400 text-white' : 'bg-white/[0.05] ring-white/15 text-white/60')
: 'bg-white/[0.02] ring-white/[0.06] text-white/30'">
<Icon v-if="d.lit || (d.isToday && checkedToday)" name="spark" class="w-3 h-3" />
<span v-else class="text-[10px] uppercase">{{ d.isToday ? '今' : i + 1 }}</span>
<div class="mt-4 flex items-center gap-1.5">
<div v-for="(d, i) in last7" :key="d.ds" class="ck-day"
:class="d.lit || (d.isToday && checkedToday) ? 'ck-lit' : d.isToday ? 'ck-today' : ''">
<Icon v-if="d.lit || (d.isToday && checkedToday)" name="spark" :size="12" />
<span v-else class="text-[10px]">{{ d.isToday ? '今' : i + 1 }}</span>
</div>
</div>
<button @click="checkin" :disabled="checkedToday"
class="mt-5 w-full rounded-xl bg-white text-black hover:bg-white/90 disabled:bg-white/10 disabled:text-white/40 disabled:cursor-not-allowed py-3 text-sm font-semibold transition-colors">
{{ checkedToday ? `今日已签到 · 明天再来` : `立即签到 +${checkinReward} 积分` }}
<button @click="checkin" :disabled="checkedToday" class="btn-primary w-full mt-5 !py-2.5">
{{ checkedToday ? '今日已签到 · 明天再来' : `立即签到 +${checkinReward} 积分` }}
</button>
</section>
<!-- RECHARGE (易支付) — hidden unless the admin enabled 充值 -->
<section v-if="payCfg.enabled" class="relative card p-7 md:p-8 overflow-hidden">
<div class="inline-grid w-10 h-10 rounded-xl bg-emerald-500/15 ring-1 ring-emerald-400/30 grid place-items-center text-emerald-300">
<Icon name="spark" class="w-4 h-4" />
<!-- ===== 积分充值 ===== -->
<section v-if="payCfg.enabled" class="card p-6">
<div class="sec-head">
<span class="sec-ico"><Icon name="spark" :size="16" /></span>
<h3>积分充值</h3>
</div>
<h2 class="text-xl font-bold mt-4">积分充值</h2>
<p class="text-sm text-white/50 mt-2">累计充值 <strong class="text-emerald-300">¥{{ rechargeTotal }}</strong> · {{ payCfg.points_ratio }} 积分 / 元</p>
<div class="grid grid-cols-3 sm:grid-cols-5 gap-2 mt-5">
<p class="text-[13px] text-[color:var(--fg-3)] mt-1">累计充值 <strong class="text-emerald-600">¥{{ rechargeTotal }}</strong> · {{ payCfg.points_ratio }} 积分 / 元</p>
<div class="grid grid-cols-3 sm:grid-cols-5 gap-2 mt-4">
<button v-for="a in AMOUNTS" :key="a" @click="picked = a" class="amt" :class="picked === a && 'amt-on'">{{ a }}元</button>
<button @click="picked = 'custom'" class="amt" :class="picked === 'custom' && 'amt-on'">自定义</button>
</div>
<input v-if="picked === 'custom'" v-model="customAmount" type="number" min="1" step="1" placeholder="输入金额(元)"
class="amt-input mt-3 w-full px-4 py-2.5 text-sm" />
<div class="flex gap-2 mt-4">
<input v-if="picked === 'custom'" v-model="customAmount" type="number" min="1" step="1" placeholder="输入金额(元)"
class="field mt-3" />
<div class="flex gap-2 mt-3">
<button v-for="m in payCfg.methods" :key="m" @click="payMethod = m" class="amt flex-1" :class="payMethod === m && 'amt-on'">{{ methodName(m) }}</button>
</div>
<div class="mt-5 flex items-center justify-between gap-3">
<span class="text-sm text-white/60">到账 <strong class="text-orange-300 text-base tabular-nums">{{ pointsPreview }}</strong> 积分</span>
<button @click="recharge" :disabled="recharging"
class="rounded-xl bg-white text-black hover:bg-white/90 disabled:opacity-60 disabled:cursor-not-allowed px-6 py-2.5 text-sm font-semibold transition-colors inline-flex items-center gap-2">
<span v-if="recharging" class="w-3.5 h-3.5 rounded-full border-2 border-black/30 border-t-black animate-spin"></span>
<span class="text-sm text-[color:var(--fg-2)]">到账 <strong class="text-amber-600 text-base tabular-nums">{{ pointsPreview }}</strong> 积分</span>
<button @click="recharge" :disabled="recharging" class="btn-primary">
<span v-if="recharging" class="w-3.5 h-3.5 rounded-full border-2 border-white/30 border-t-white animate-spin"></span>
{{ recharging ? '下单中…' : '立即充值' }}
</button>
</div>
</section>
<!-- CDK REDEEM — hidden when the admin turns the 兑换码 switch off -->
<section v-if="site.cdkRedeemEnabled" class="relative card p-7 md:p-8 overflow-hidden">
<div class="inline-grid w-10 h-10 rounded-xl bg-emerald-500/15 ring-1 ring-emerald-400/30 grid place-items-center text-emerald-300">
<Icon name="spark" class="w-4 h-4" />
<!-- ===== 兑换码 ===== -->
<section v-if="site.cdkRedeemEnabled" class="card p-6">
<div class="sec-head">
<span class="sec-ico"><Icon name="spark" :size="16" /></span>
<h3>兑换码充值</h3>
</div>
<h2 class="text-xl font-bold mt-4">兑换码充值</h2>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-bold tabular-nums">{{ points(balance).toLocaleString('en-US') }}</span>
<span class="text-xs text-white/40 uppercase tracking-widest">积分余额</span>
<div class="mt-3 flex items-baseline gap-2">
<span class="text-3xl font-extrabold tabular-nums tracking-tight">{{ points(balance).toLocaleString('en-US') }}</span>
<span class="text-xs text-[color:var(--fg-3)]">积分余额</span>
</div>
<div class="mt-5 flex gap-2">
<input v-model="cdkCode" @keyup.enter="redeemCdk" placeholder="输入兑换码 (CDK)"
class="flex-1 rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm font-mono uppercase tracking-wider outline-none transition-colors placeholder:text-white/30 placeholder:normal-case placeholder:tracking-normal" />
<button @click="redeemCdk" :disabled="cdkBusy"
class="rounded-xl bg-white text-black hover:bg-white/90 disabled:opacity-40 px-5 py-3 text-sm font-semibold transition-colors">
{{ cdkBusy ? '兑换中…' : '兑换' }}
</button>
<div class="mt-4 flex gap-2">
<input v-model="cdkCode" @keyup.enter="redeemCdk" placeholder="输入兑换码(CDK)"
class="field flex-1 font-mono uppercase tracking-wider" />
<button @click="redeemCdk" :disabled="cdkBusy" class="btn-primary shrink-0">{{ cdkBusy ? '兑换中…' : '兑换' }}</button>
</div>
<p v-if="cdkError" class="text-xs text-rose-400 mt-2">{{ cdkError }}</p>
<p class="text-[11px] text-white/35 mt-3">兑换码每个仅可使用一次。还没有兑换码?到商店购买后回此处充值。</p>
<a v-if="site.contact?.shop" :href="site.contact.shop" target="_blank" rel="noopener"
class="mt-3 group inline-flex items-center gap-2 rounded-xl bg-emerald-500/15 ring-1 ring-emerald-400/30 text-emerald-300 hover:bg-emerald-500/25 px-4 py-2.5 text-sm font-semibold transition-colors">
<Icon name="spark" class="w-4 h-4" />
前往商店购买兑换码
<span class="group-hover:translate-x-0.5 transition-transform">→</span>
<p v-if="cdkError" class="text-xs text-rose-600 mt-2">{{ cdkError }}</p>
<p class="text-[11px] text-[color:var(--fg-faint)] mt-3">兑换码每个仅可使用一次。还没有兑换码?到商店购买后回此处充值。</p>
<a v-if="site.contact?.shop" :href="site.contact.shop" target="_blank" rel="noopener" class="btn-soft mt-3">
前往商店购买兑换码 <span aria-hidden="true">→</span>
</a>
</section>
<!-- PASSWORD -->
<section class="card p-7 md:p-8">
<div class="inline-grid w-10 h-10 rounded-xl bg-amber-500/15 ring-1 ring-amber-400/30 grid place-items-center text-amber-300">
<Icon name="refresh" class="w-4 h-4" />
<!-- ===== 修改密码 ===== -->
<section class="card p-6">
<div class="sec-head">
<span class="sec-ico"><Icon name="config" :size="16" /></span>
<h3>修改密码</h3>
</div>
<h2 class="text-xl font-bold mt-4">修改密码</h2>
<p class="text-sm text-white/50 mt-2">8-24 位,必须包含大写字母、小写字母、数字和符号。</p>
<div class="mt-6 space-y-3">
<input v-model="pwdForm.current" type="password" placeholder="当前密码" autocomplete="current-password"
class="w-full rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm outline-none transition-colors placeholder:text-white/30" />
<input v-model="pwdForm.next" type="password" placeholder="新密码(8-24位,含大小写/数字/符号)" autocomplete="new-password"
class="w-full rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm outline-none transition-colors placeholder:text-white/30" />
<input v-model="pwdForm.confirm" type="password" placeholder="再次输入新密码" autocomplete="new-password"
class="w-full rounded-xl bg-white/[0.05] ring-1 ring-white/10 focus:ring-white/30 px-4 py-3 text-sm outline-none transition-colors placeholder:text-white/30" />
<p v-if="pwdError" class="text-xs text-rose-400">{{ pwdError }}</p>
<button @click="changePwd" :disabled="pwdSubmitting"
class="w-full rounded-xl bg-white text-black hover:bg-white/90 disabled:opacity-40 py-3 text-sm font-semibold transition-colors">
{{ pwdSubmitting ? '提交中…' : '更新密码' }}
</button>
<p class="text-[13px] text-[color:var(--fg-3)] mt-1">8-24 位,必须包含大写字母、小写字母、数字和符号。</p>
<div class="mt-4 space-y-3">
<input v-model="pwdForm.current" type="password" placeholder="当前密码" autocomplete="current-password" class="field" />
<input v-model="pwdForm.next" type="password" placeholder="新密码(8-24 位,含大小写 / 数字 / 符号)" autocomplete="new-password" class="field" />
<input v-model="pwdForm.confirm" type="password" placeholder="再次输入新密码" autocomplete="new-password" class="field" />
<p v-if="pwdError" class="text-xs text-rose-600">{{ pwdError }}</p>
<button @click="changePwd" :disabled="pwdSubmitting" class="btn-primary w-full !py-2.5">{{ pwdSubmitting ? '提交中…' : '更新密码' }}</button>
</div>
</section>
<!-- SIGN OUT (full width) -->
<section class="lg:col-span-2 card p-6 md:p-7 flex items-center gap-5 flex-wrap">
<div class="inline-grid w-10 h-10 rounded-xl bg-rose-500/15 ring-1 ring-rose-400/30 grid place-items-center text-rose-300">
<Icon name="close" class="w-4 h-4" />
</div>
<!-- ===== 退出登录 ===== -->
<section class="card p-5 flex items-center gap-4 flex-wrap">
<span class="sec-ico"><Icon name="close" :size="16" /></span>
<div class="flex-1 min-w-0">
<div class="text-base font-semibold">退出登录</div>
<p class="text-sm text-white/50 mt-1">会清除本地 API Key、余额缓存。下次进来要重新设置。</p>
<div class="text-sm font-bold">退出登录</div>
<p class="text-xs text-[color:var(--fg-3)] mt-0.5">会清除本地 API Key 与缓存,下次进入需要重新登录。</p>
</div>
<button @click="logout"
class="rounded-xl bg-rose-500/20 text-rose-200 hover:bg-rose-500/30 ring-1 ring-rose-500/30 px-5 py-2.5 text-sm font-semibold transition-colors">
退出
</button>
<button @click="logout" class="btn-danger-soft">退出</button>
</section>
</div>
<!-- toast -->
<transition name="fade">
<div v-if="toastMsg"
class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-white text-black text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">
class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-zinc-900 text-white text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">
{{ toastMsg }}
</div>
</transition>
@@ -475,8 +407,45 @@ async function recharge() {
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(8px); }
/* Recharge amount / method buttons — theme-aware (clean in light AND dark).
Selected uses the inverted solid-button color, not a harsh orange. */
.sec-head { display: flex; align-items: center; gap: 8px; }
.sec-head h3 { font-size: 15px; font-weight: 800; letter-spacing: -0.01em; color: var(--fg); }
.sec-ico {
width: 34px; height: 34px; flex: none; border-radius: 10px;
display: grid; place-items: center;
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
color: var(--fg-2);
}
.st-label { font-size: 11px; font-weight: 700; letter-spacing: .05em; color: var(--fg-faint); margin-bottom: 9px; }
.st-value { font-size: 14.5px; font-weight: 600; color: var(--fg); line-height: 1.5; }
.st-num { font-variant-numeric: tabular-nums; }
.key-code { background: var(--surface-2); padding: 1px 6px; border-radius: 5px; font-size: 12px; }
.key-box {
display: flex; align-items: center; gap: 8px;
padding: 8px 8px 8px 14px; border-radius: 12px;
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
}
.btn-danger-soft {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
height: 36px; padding: 0 16px; border-radius: 10px;
font-size: 13px; font-weight: 700;
color: rgb(190 18 60); background: rgb(244 63 94 / 0.08);
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.22);
transition: background .15s ease, color .15s ease;
}
.btn-danger-soft:hover { color: #fff; background: rgb(225 29 72); }
.ck-day {
flex: 1; height: 46px; border-radius: 10px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
font-weight: 600;
background: var(--surface-2); color: var(--fg-faint);
box-shadow: inset 0 0 0 1px var(--hairline);
}
.ck-today { color: var(--fg-2); box-shadow: inset 0 0 0 1.5px rgb(24 24 27 / 0.4); }
.ck-lit { background: #18181b; color: #fff; box-shadow: none; }
/* Recharge amount / method buttons */
.amt {
border-radius: 0.6rem;
padding: 0.6rem 0;
@@ -488,17 +457,5 @@ async function recharge() {
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.amt:hover { color: var(--fg); background: var(--hover); }
.amt-on {
background: var(--btn-solid-bg);
color: var(--btn-solid-fg);
box-shadow: none;
}
.amt-input {
border-radius: 0.7rem;
color: var(--fg);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
outline: none;
}
.amt-input:focus { box-shadow: inset 0 0 0 1px var(--fg-3); }
.amt-on { background: var(--btn-solid-bg); color: var(--btn-solid-fg); box-shadow: none; }
</style>
+16 -26
View File
@@ -1,17 +1,16 @@
<script setup>
// Admin → 首页内容
// CRUD for the cards rendered on the public home page:
// - hero : top-3-by-weight stacked deck in the hero
// - bento : "从一个起点开始" grid
// - hero : top-4-by-weight collage in the hero
// - work : "我们的作品" marquee — admin-curated featured outputs
// All three kinds use a real image as the background; admins pick one from
// Both kinds use a real image as the background; admins pick one from
// the already-generated files or paste an external URL.
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { api, jsonBody, generatedUrl } from '../api'
import Icon from '../components/Icon.vue'
const items = ref([])
const filter = ref('all') // 'all' | 'hero' | 'bento' | 'work'
const filter = ref('all') // 'all' | 'hero' | 'work'
const loading = ref(false)
const editing = ref(null) // truthy when the form modal is open
const picking = ref(false) // truthy when the image-picker modal is open
@@ -70,7 +69,7 @@ const pageNumbers = computed(() => {
return out
})
const KIND_DEFAULT_WEIGHT = { hero: 200, bento: 300, work: 100 }
const KIND_DEFAULT_WEIGHT = { hero: 200, work: 100 }
function openNew(kind) {
editing.value = { kind }
@@ -174,24 +173,30 @@ function bgFor(image) {
}
}
const SPAN_PRESETS = ['', 'md:col-span-2', 'md:row-span-2', 'md:col-span-2 md:row-span-2']
onMounted(refresh)
</script>
<template>
<section class="space-y-4">
<!-- header / filter / new -->
<div class="page-head">
<div>
<h2 class="page-title">首页内容</h2>
<p class="page-sub">首页首屏拼贴(Hero)与作品墙(Work)的展示图</p>
</div>
<div class="page-actions">
<button @click="refresh" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
</div>
<!-- filter / new -->
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-1.5">
<button @click="setFilter('all')" class="filter-pill" :class="filter === 'all' && 'on'">全部</button>
<button @click="setFilter('hero')" class="filter-pill" :class="filter === 'hero' && 'on'">Hero 卡片</button>
<button @click="setFilter('bento')" class="filter-pill" :class="filter === 'bento' && 'on'">Bento 灵感</button>
<button @click="setFilter('work')" class="filter-pill" :class="filter === 'work' && 'on'">我们的作品</button>
</div>
<div class="flex items-center gap-2">
<button @click="openNew('hero')" class="btn-soft">+ Hero</button>
<button @click="openNew('bento')" class="btn-soft">+ Bento</button>
<button @click="openNew('work')" class="btn-soft">+ Work</button>
</div>
</div>
@@ -209,7 +214,6 @@ onMounted(refresh)
<div class="absolute top-3 left-3 flex items-center gap-1.5">
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded ring-1"
:class="rec.kind === 'hero' ? 'bg-rose-500/20 text-rose-200 ring-rose-400/30'
: rec.kind === 'bento' ? 'bg-orange-500/20 text-orange-200 ring-orange-400/30'
: 'bg-sky-500/20 text-sky-200 ring-sky-400/30'">
{{ rec.kind }}
</span>
@@ -255,7 +259,7 @@ onMounted(refresh)
<div class="px-5 py-3 border-b border-[color:var(--hairline)] flex items-center justify-between">
<h2 class="text-sm font-semibold">
{{ form.id ? '编辑' : '新增' }} ·
{{ form.kind === 'hero' ? 'Hero 卡片' : form.kind === 'bento' ? 'Bento 灵感' : '作品' }}
{{ form.kind === 'hero' ? 'Hero 卡片' : '作品' }}
</h2>
<button @click="closeForm" class="text-[color:var(--fg-faint)] hover:text-[color:var(--fg)]">
<Icon name="close" class="w-4 h-4" />
@@ -293,7 +297,6 @@ onMounted(refresh)
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">类型</label>
<div class="flex gap-1.5">
<button type="button" @click="form.kind = 'hero'" class="kind-btn" :class="form.kind === 'hero' && 'on'">Hero</button>
<button type="button" @click="form.kind = 'bento'" class="kind-btn" :class="form.kind === 'bento' && 'on'">Bento</button>
<button type="button" @click="form.kind = 'work'" class="kind-btn" :class="form.kind === 'work' && 'on'">Work</button>
</div>
</div>
@@ -315,7 +318,7 @@ onMounted(refresh)
</div>
</div>
<div>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">提示词 <span class="text-[color:var(--fg-faint)]">(点 Bento 后会预填到画图)</span></label>
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">提示词 <span class="text-[color:var(--fg-faint)]">(点图后会预填到画图)</span></label>
<textarea v-model="form.prompt" rows="3" class="field resize-none"
placeholder="一位身穿米色风衣的女子站在雨夜的霓虹街道,胶片质感,浅景深,电影感"></textarea>
</div>
@@ -326,19 +329,6 @@ onMounted(refresh)
<input v-model="form.title" class="field" placeholder="留空则只展示图片" />
</div>
</template>
<div v-if="form.kind === 'bento'">
<label class="block text-xs text-[color:var(--fg-3)] mb-1.5">网格跨度 <span class="text-[color:var(--fg-faint)]">(Tailwind class)</span></label>
<div class="flex gap-1.5 flex-wrap mb-2">
<button v-for="s in SPAN_PRESETS" :key="s" type="button" @click="form.span = s"
class="px-2.5 py-1 text-[11px] rounded-lg ring-1 ring-[color:var(--hairline)] hover:bg-[color:var(--hover)]"
:class="form.span === s ? 'bg-[color:var(--btn-solid-bg)] text-[color:var(--btn-solid-fg)]' : 'bg-[color:var(--surface-2)] text-[color:var(--fg-2)]'">
{{ s || '默认 1×1' }}
</button>
</div>
<input v-model="form.span" class="field font-mono text-[11px]" placeholder="md:col-span-2" />
</div>
<p v-if="error" class="text-xs text-rose-500">{{ error }}</p>
</div>
+14 -22
View File
@@ -10,6 +10,7 @@ import { fmtDate, fmtClock, fmtTs } from '../utils/format'
import { copyText } from '../utils/clipboard'
import { points } from '../credits'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
const router = useRouter()
@@ -44,6 +45,8 @@ async function copyError(e) {
// 来源筛选走服务端:画图台 = source "user",API = source "v1"。
const SOURCE_PARAM = { web: 'user', api: 'v1' }
const STATUS_OPTS = [{ value: '', label: '所有状态' }, { value: 'success', label: '成功' }, { value: 'failed', label: '失败' }, { value: 'pending', label: '进行中' }]
const SOURCE_OPTS = [{ value: '', label: '全部来源' }, { value: 'web', label: '画图台' }, { value: 'api', label: 'API' }]
// 服务端分页 —— 不再只拉前 200 条;按页向后端取,可翻到全部历史。
async function load() {
@@ -127,14 +130,14 @@ const params = (e) => {
<template>
<section class="space-y-5 log-page">
<!-- Header -->
<div class="flex items-end justify-between flex-wrap gap-3">
<div class="page-head">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">生成日志</h1>
<p class="text-sm text-slate-500 mt-1">{{ total }} 条记录 · 含失败原因</p>
<h2 class="page-title">生成日志</h2>
<p class="page-sub">{{ total }} 条记录 · 含失败原因</p>
</div>
<div class="page-actions">
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
<button @click="router.push('/user')" class="btn-primary">
<Icon name="spark" class="w-4 h-4" /> 去画图
</button>
</div>
<!-- KPI 统计(本人累计) -->
@@ -158,25 +161,14 @@ const params = (e) => {
</div>
<!-- Filter bar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1.5">
<button v-for="s in [['','全部'],['success','成功'],['failed','失败'],['pending','进行中']]" :key="s[0]"
@click="setStatus(s[0])"
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="statusFilter === s[0] ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
</div>
<div class="w-px h-5 bg-slate-200"></div>
<div class="flex items-center gap-1.5">
<button v-for="s in [['','全部来源'],['web','画图台'],['api','API']]" :key="s[0]"
@click="setSource(s[0])"
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="sourceFilter === s[0] ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
</div>
<div class="card toolbar">
<SelectMenu :model-value="statusFilter" compact :options="STATUS_OPTS" @update:model-value="setStatus" />
<SelectMenu :model-value="sourceFilter" compact :options="SOURCE_OPTS" @update:model-value="setSource" />
<div class="flex-1 min-w-[180px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
class="field !py-1.5 text-xs" placeholder="搜索 提示词 / 模型 / 错误…" />
</div>
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
<button @click="router.push('/user')" class="btn-primary ml-auto"><Icon name="spark" class="w-3.5 h-3.5" /> 去画图</button>
</div>
<!-- States -->
@@ -257,7 +249,7 @@ const params = (e) => {
:title="e.error + ' — 点击复制'"
@click.stop="copyError(e)">⚠ {{ e.error }}</div>
</td>
<td class="px-3 py-3 align-middle text-xs text-slate-500 tabular-nums">{{ params(e) || '—' }}<span v-if="e.deai"> · <span class="text-[#a78bfa]">去AI特征</span></span></td>
<td class="px-3 py-3 align-middle text-xs text-slate-500 tabular-nums">{{ params(e) || '—' }}<span v-if="e.deai"> · <span class="text-[#71717a]">去AI特征</span></span></td>
<td class="px-3 py-3 align-middle text-right text-xs text-slate-700 tabular-nums">{{ e.cost ? points(e.cost) : '—' }}</td>
<td class="px-3 py-3 align-middle text-right text-xs text-slate-500 tabular-nums">{{ e.elapsed_ms ? (e.elapsed_ms / 1000).toFixed(1) + 's' : '—' }}</td>
</tr>
+27 -30
View File
@@ -6,6 +6,7 @@ import { fmtTs } from '../utils/format'
import { copyText } from '../utils/clipboard'
import { zipSync } from 'fflate'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
const router = useRouter()
@@ -46,6 +47,7 @@ const filtered = computed(() => items.value)
function doSearch() { page.value = 1; load() }
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const KIND_OPTS = [{ value: '', label: '全部类型' }, { value: 'image', label: '图像' }, { value: 'video', label: '视频' }]
function setKind(v) { kindFilter.value = v; page.value = 1; load() }
function goPage(n) {
const target = Math.max(1, Math.min(totalPages.value, n))
@@ -231,44 +233,39 @@ onUnmounted(() => {
<template>
<section class="space-y-5">
<!-- Header -->
<div class="flex items-end justify-between flex-wrap gap-3">
<div class="page-head">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">我的创作记录</h1>
<p class="text-sm text-slate-500 mt-1">
{{ total }} 条作品
</p>
<h2 class="page-title">我的创作记录</h2>
<p class="page-sub">{{ total }} 条作品</p>
</div>
<div class="page-actions">
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
<button @click="router.push('/user')" class="btn-primary">
<Icon name="spark" class="w-4 h-4" /> 去画图
</button>
</div>
<!-- Filter bar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1.5">
<button @click="setKind('')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="kindFilter === '' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">全部</button>
<button @click="setKind('image')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="kindFilter === 'image' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">图像</button>
<button @click="setKind('video')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="kindFilter === 'video' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">视频</button>
</div>
<div class="card toolbar">
<SelectMenu :model-value="kindFilter" compact :options="KIND_OPTS" @update:model-value="setKind" />
<div class="flex-1 min-w-[180px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
class="field !py-1.5 text-xs" placeholder="搜索提示词或模型…" />
class="field" placeholder="搜索提示词或模型…" />
</div>
<button @click="togglePickAll" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors inline-flex items-center gap-1"
:class="pageAllPicked ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
<Icon name="check" class="w-3.5 h-3.5" /> 全选本页
</button>
<div class="ml-auto flex items-center gap-2 flex-wrap">
<template v-if="picked.size">
<button @click="downloadPicked" :disabled="zipping" class="text-xs rounded-lg px-2.5 py-1.5 bg-slate-900 text-white hover:bg-slate-700 inline-flex items-center gap-1 disabled:opacity-50">
<Icon name="download" class="w-3.5 h-3.5" /> {{ zipping ? '打包中…' : `下载选中 (${picked.size})` }}
</button>
<button @click="deletePicked" class="text-xs rounded-lg px-2.5 py-1.5 bg-rose-600 text-white hover:bg-rose-500 inline-flex items-center gap-1">
<button @click="deletePicked" class="btn-soft danger">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ picked.size }})
</button>
<button @click="downloadPicked" :disabled="zipping" class="btn-soft disabled:opacity-50">
<Icon name="download" class="w-3.5 h-3.5" /> {{ zipping ? '打包中…' : `下载选中 (${picked.size})` }}
</button>
</template>
<button @click="togglePickAll" class="btn-soft">
<Icon name="check" class="w-3.5 h-3.5" /> {{ pageAllPicked ? '取消全选' : '全选本页' }}
</button>
<button @click="router.push('/user')" class="btn-primary">
<Icon name="spark" class="w-3.5 h-3.5" /> 去画图
</button>
</div>
</div>
<!-- Empty -->
@@ -315,7 +312,7 @@ onUnmounted(() => {
<div>
<Icon name="close" class="w-6 h-6 mx-auto mb-2 opacity-60" />
<div>生成失败</div>
<div v-if="e.error" class="text-[10px] text-rose-400 line-clamp-2 mt-1">{{ e.error }}</div>
<div v-if="e.error" class="text-[10px] text-rose-600 line-clamp-2 mt-1">{{ e.error }}</div>
</div>
</div>
@@ -358,10 +355,10 @@ onUnmounted(() => {
<div class="text-[10px] text-white/55 flex items-center justify-between gap-2 tabular-nums">
<span class="break-all" :title="e.model || ''">{{ e.model || '—' }}</span>
<span class="shrink-0 flex items-center gap-1">
<span v-if="e.resolution" class="text-emerald-300/90">{{ e.resolution }}</span>
<span v-if="e.resolution" class="text-emerald-600">{{ e.resolution }}</span>
<span v-if="e.ratio" class="text-white/40">{{ e.ratio }}</span>
<span v-if="e.kind === 'video' && e.duration" class="text-rose-300/80">{{ e.duration }}</span>
<span v-if="e.deai" class="text-[#a78bfa]">去AI</span>
<span v-if="e.kind === 'video' && e.duration" class="text-rose-600">{{ e.duration }}</span>
<span v-if="e.deai" class="text-[#71717a]">去AI</span>
</span>
</div>
<div class="text-[10px] text-white/35 mt-0.5 tabular-nums">{{ fmtTs(e.ts) }}<span v-if="e.elapsed_ms"> · {{ fmtMs(e.elapsed_ms) }}</span></div>
+145 -138
View File
@@ -38,6 +38,33 @@ const ROLE_OPTIONS = [
]
const roleLabel = (r) => ({ user: '用户', agent: '代理', admin: '管理员' }[r] || '用户')
// 头像淡彩:按用户名哈希出固定的一对浅底深字色
const AVATAR_TONES = [
['#dbeafe', '#1d4ed8'], ['#dcfce7', '#15803d'], ['#fef3c7', '#b45309'],
['#fce7f3', '#be185d'], ['#ede9fe', '#6d28d9'], ['#cffafe', '#0e7490'],
['#fee2e2', '#b91c1c'], ['#ffedd5', '#c2410c'], ['#ecfccb', '#4d7c0f'],
]
const avatarStyle = (u) => {
const s = u.name || u.email || '?'
let h = 0
for (const c of s) h = (h * 31 + c.charCodeAt(0)) >>> 0
const [background, color] = AVATAR_TONES[h % AVATAR_TONES.length]
return { background, color }
}
// 窄窗口下表格横向溢出:把鼠标纵向滚轮映射成横向滚动;
// 滚到左/右尽头就放行,页面正常纵向滚动。
const onTableWheel = (e) => {
const el = e.currentTarget
if (el.scrollWidth <= el.clientWidth) return
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
const max = el.scrollWidth - el.clientWidth
const next = el.scrollLeft + e.deltaY
if ((next <= 0 && el.scrollLeft <= 0) || (next >= max && el.scrollLeft >= max)) return
el.scrollLeft = next
e.preventDefault()
}
// Concurrency groups — resolve a user's group id → name/limit for the table,
// and offer them in the edit form.
const cgroups = ref([])
@@ -85,7 +112,14 @@ function goPage(n) {
if (target !== page.value) page.value = target
}
watch(page, () => { load() })
function setFilter(fn) { fn(); resetAndLoad() }
const ROLE_OPTS = [
{ value: '', label: '全部角色' }, { value: 'admin', label: '管理员' },
{ value: 'agent', label: '代理' }, { value: 'user', label: '用户' },
]
const STATUS_OPTS = [
{ value: '', label: '所有状态' }, { value: 'active', label: '正常' }, { value: 'disabled', label: '禁用' },
]
watch([roleFilter, statusFilter], resetAndLoad)
function resetAndLoad() {
if (page.value !== 1) page.value = 1
else load()
@@ -210,57 +244,40 @@ async function doRecharge() {
<template>
<section class="space-y-4">
<div class="page-head">
<h2 class="page-title">用户管理</h2>
<button @click="load" class="refresh-btn" title="刷新" aria-label="刷新"><Icon name="refresh" :size="18" /></button>
</div>
<!-- KPI strip — same shape as LogsView / ModelsView -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-white/45">用户总数</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">用户总数</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-emerald-300/80">正常</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-emerald-300">{{ stats.active }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">正常</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.active }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-rose-300/80">管理员</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-rose-300">{{ stats.admins }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">管理员</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.admins }}</div>
</div>
<div class="card p-4">
<div class="text-[11px] uppercase tracking-wider text-amber-300/80">总积分</div>
<div class="text-2xl font-semibold mt-1 tabular-nums text-amber-300">{{ points(stats.credits_total).toLocaleString('en-US') }}</div>
<div class="text-[11px] uppercase tracking-wider text-[color:var(--fg-3)]">总积分</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ points(stats.credits_total).toLocaleString('en-US') }}</div>
</div>
</div>
<!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1">
<button @click="setFilter(() => roleFilter = '')" class="fp" :class="roleFilter === '' && 'fp-on'">全部角色</button>
<button @click="setFilter(() => roleFilter = 'admin')" class="fp" :class="roleFilter === 'admin' && 'fp-orange'">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>管理员
</button>
<button @click="setFilter(() => roleFilter = 'agent')" class="fp" :class="roleFilter === 'agent' && 'fp-amber'">
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>代理
</button>
<button @click="setFilter(() => roleFilter = 'user')" class="fp" :class="roleFilter === 'user' && 'fp-on'">用户</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>正常
</button>
<button @click="setFilter(() => statusFilter = 'disabled')" class="fp" :class="statusFilter === 'disabled' && 'fp-rose'">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>禁用
</button>
</div>
<div class="card toolbar">
<SelectMenu v-model="roleFilter" compact :options="ROLE_OPTS" />
<SelectMenu v-model="statusFilter" compact :options="STATUS_OPTS" />
<div class="flex-1 min-w-[200px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / 用户名 / ID…" />
</div>
<button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的用户">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
</button>
<button @click="load" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
<button @click="showAdd = true" class="btn-primary">
<Icon name="plus" class="w-3.5 h-3.5" /> 新建用户
</button>
@@ -268,33 +285,30 @@ async function doRecharge() {
<!-- Table -->
<div class="card overflow-hidden">
<div v-if="loading && !items.length" class="text-center text-sm text-white/40 py-20">加载中…</div>
<div v-else-if="!filtered.length" class="flex flex-col items-center gap-3 text-white/40 py-20">
<span class="w-14 h-14 rounded-2xl bg-white/[0.04] grid place-items-center">
<div v-if="loading && !items.length" class="empty"><span class="empty-text">加载中…</span></div>
<div v-else-if="!filtered.length" class="empty">
<span class="empty-ico">
<Icon name="accounts" class="w-6 h-6" />
</span>
<span class="text-sm">{{ stats.total ? '没有匹配的用户' : '还没有用户' }}</span>
<button v-if="!stats.total" @click="showAdd = true" class="btn-soft mt-1">新建第一个</button>
<span class="empty-text">{{ stats.total ? '没有匹配的用户' : '还没有用户' }}</span>
<button v-if="!stats.total" @click="showAdd = true" class="btn-soft">新建第一个</button>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-sm table-fixed min-w-[1360px]">
<div v-else class="overflow-x-auto" @wheel="onTableWheel">
<table class="utable w-full text-sm table-fixed min-w-[1200px]">
<colgroup>
<col class="w-9" /> <!-- select -->
<col class="w-36" /> <!-- username -->
<col /> <!-- email (flex) -->
<col class="w-24" /> <!-- notes -->
<col class="w-28" /> <!-- concurrency -->
<col class="w-10" /> <!-- select -->
<col /> <!-- user (avatar+name+email+notes) -->
<col class="w-20" /> <!-- role -->
<col class="w-16" /> <!-- status switch -->
<col class="w-24" /> <!-- credits -->
<col class="w-24" /> <!-- recharge total -->
<col class="w-20" /> <!-- generation count -->
<col class="w-20" /> <!-- banned word hits -->
<col class="w-14" /> <!-- status switch -->
<col class="w-20" /> <!-- credits -->
<col class="w-20" /> <!-- recharge total -->
<col class="w-28" /> <!-- concurrency -->
<col class="w-16" /> <!-- generation count -->
<col class="w-16" /> <!-- banned word hits -->
<col class="w-28" /> <!-- registered -->
<col class="w-28" /> <!-- last login -->
<col class="w-32" /> <!-- login IP -->
<col class="w-32" /> <!-- actions -->
<col class="w-40" /> <!-- last login + ip -->
<col class="w-36" /> <!-- actions -->
</colgroup>
<thead>
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
@@ -302,50 +316,46 @@ async function doRecharge() {
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
class="chk" title="全选" />
</th>
<th class="text-left px-3 py-3 font-medium">用户名</th>
<th class="text-left px-3 py-3 font-medium">邮箱</th>
<th class="text-left px-3 py-3 font-medium">备注</th>
<th class="text-left px-3 py-3 font-medium">并发</th>
<th class="text-left px-3 py-3 font-medium">用户</th>
<th class="text-left px-3 py-3 font-medium">角色</th>
<th class="text-left px-3 py-3 font-medium">状态</th>
<th class="text-right px-3 py-3 font-medium">积分</th>
<th class="text-right px-3 py-3 font-medium">累计充值</th>
<th class="text-left px-3 py-3 font-medium">并发组</th>
<th class="text-right px-3 py-3 font-medium">生图次数</th>
<th class="text-right px-3 py-3 font-medium">违禁触发</th>
<th class="text-left px-3 py-3 font-medium">注册时间</th>
<th class="text-left px-3 py-3 font-medium">最近登录</th>
<th class="text-left px-3 py-3 font-medium">登录 IP</th>
<th class="text-right px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="u in pagedItems" :key="u.id"
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors"
:class="selected.has(u.id) && 'row-on'">
<td class="px-3 py-3.5 align-middle text-center">
<input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" @click.stop
class="chk" />
</td>
<td class="px-3 py-3.5 align-middle text-sm font-medium text-white/90 truncate" :title="u.name || '—'">
{{ u.name || '—' }}
</td>
<td class="px-3 py-3.5 align-middle text-xs text-white/75 truncate" :title="u.email">
{{ u.email || '—' }}
</td>
<td class="px-3 py-3.5 align-middle text-xs truncate" :class="u.notes ? 'text-white/70' : 'text-white/25'" :title="u.notes || ''">
{{ u.notes || '—' }}
</td>
<td class="px-3 py-3.5 align-middle text-xs truncate text-white/70" :title="cgroupLabel(u.concurrency_group_id)">
{{ cgroupLabel(u.concurrency_group_id) }}
<td class="px-3 py-3.5 align-middle">
<div class="flex items-center gap-2.5 min-w-0">
<span class="uavatar" :style="avatarStyle(u)">{{ (u.name || u.email || '?').slice(0, 1).toUpperCase() }}</span>
<div class="min-w-0">
<div class="text-[13.5px] font-semibold text-white/90 truncate" :title="u.name || '—'">{{ u.name || '—' }}</div>
<div class="text-xs text-white/50 truncate" :title="u.email">{{ u.email || '—' }}</div>
<div v-if="u.notes" class="text-[11px] text-white/35 truncate" :title="u.notes">{{ u.notes }}</div>
</div>
</div>
</td>
<td class="px-3 py-3.5 align-middle">
<span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap"
:class="u.role === 'admin'
? 'bg-rose-500/10 text-rose-300 ring-rose-400/30'
? 'bg-rose-500/10 text-rose-600 ring-rose-500/25'
: u.role === 'agent'
? 'bg-amber-500/10 text-amber-300 ring-amber-400/30'
? 'bg-amber-500/10 text-amber-600 ring-amber-500/25'
: 'bg-white/[0.06] text-white/70 ring-white/15'">
<span class="w-1.5 h-1.5 rounded-full"
:class="u.role === 'admin' ? 'bg-rose-400' : u.role === 'agent' ? 'bg-amber-400' : 'bg-slate-400'"></span>
:class="u.role === 'admin' ? 'bg-rose-500' : u.role === 'agent' ? 'bg-amber-500' : 'bg-slate-400'"></span>
{{ roleLabel(u.role) }}
</span>
</td>
@@ -361,33 +371,30 @@ async function doRecharge() {
{{ points(u.credits).toLocaleString('en-US') }}
</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
:class="u.recharge_total > 0 ? 'text-emerald-300' : 'text-white/25'">
:class="u.recharge_total > 0 ? 'text-emerald-600' : 'text-white/25'">
¥{{ (u.recharge_total || 0).toLocaleString('en-US') }}
</td>
<td class="px-3 py-3.5 align-middle text-xs truncate text-white/60" :title="cgroupLabel(u.concurrency_group_id)">
{{ cgroupLabel(u.concurrency_group_id) }}
</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
:class="u.generation_count > 0 ? 'text-white/85' : 'text-white/25'">
{{ (u.generation_count || 0).toLocaleString('en-US') }}
</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
:class="u.banned_word_hits > 0 ? 'text-rose-300' : 'text-white/25'">
:class="u.banned_word_hits > 0 ? 'text-rose-600' : 'text-white/25'">
{{ (u.banned_word_hits || 0).toLocaleString('en-US') }}
</td>
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
<div v-if="u.created_at" class="leading-tight" :title="fmtTs(u.created_at)">
<div class="text-white/65 tabular-nums">{{ fmtDate(u.created_at) }}</div>
<div class="text-white/35 tabular-nums">{{ fmtClock(u.created_at) }}</div>
</div>
<td class="px-3 py-3.5 align-middle whitespace-nowrap">
<span v-if="u.created_at" class="text-[13px] text-white/65 tabular-nums" :title="fmtTs(u.created_at)">{{ fmtDate(u.created_at) }}</span>
<span v-else class="text-white/25">—</span>
</td>
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
<div v-if="u.last_login_at" class="leading-tight" :title="fmtTs(u.last_login_at)">
<div class="text-white/65 tabular-nums">{{ fmtDate(u.last_login_at) }}</div>
<div class="text-white/35 tabular-nums">{{ fmtClock(u.last_login_at) }}</div>
<td class="px-3 py-3.5 align-middle whitespace-nowrap">
<div v-if="u.last_login_at" class="leading-snug" :title="fmtTs(u.last_login_at)">
<div class="text-[13px] text-white/65 tabular-nums">{{ fmtDate(u.last_login_at) }} {{ fmtClock(u.last_login_at) }}</div>
<div class="text-[11px] text-white/40 font-mono truncate" :title="u.last_login_ip || ''">{{ u.last_login_ip || '—' }}</div>
</div>
<span v-else class="text-white/25">从未登录</span>
</td>
<td class="px-3 py-3.5 align-middle text-xs font-mono text-white/55 truncate" :title="u.last_login_ip || ''">
{{ u.last_login_ip || '—' }}
<span v-else class="text-[13px] text-white/25">从未登录</span>
</td>
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
<div class="inline-flex items-center gap-1">
@@ -436,7 +443,7 @@ async function doRecharge() {
</div>
<div class="p-5 space-y-3">
<div>
<label class="lbl">邮箱 <span class="text-rose-300">*</span></label>
<label class="lbl">邮箱 <span class="text-rose-600">*</span></label>
<input v-model="addForm.email" class="field" placeholder="user@example.com" />
</div>
<div>
@@ -570,7 +577,7 @@ async function doRecharge() {
display: block;
font-size: 0.72rem;
font-weight: 500;
color: rgb(255 255 255 / 0.55);
color: var(--fg-3);
margin-bottom: 0.4rem;
}
@@ -579,58 +586,61 @@ async function doRecharge() {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.35rem 0.7rem; font-size: 0.72rem;
border-radius: 0.55rem;
color: rgb(255 255 255 / 0.65);
background: rgb(255 255 255 / 0.05);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.06);
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.fp:hover { background: rgb(255 255 255 / 0.09); color: white; }
.fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
.fp-emerald {
background: rgb(16 185 129 / 0.22);
color: rgb(110 231 183);
box-shadow: inset 0 0 0 1px rgb(110 231 183 / 0.45);
.fp:hover { background: var(--hover); color: var(--fg); }
.fp-on { background: var(--surface-solid); color: rgb(15 23 42); box-shadow: none; }
.fp-emerald { background: rgb(16 185 129 / 0.1); color: #059669; box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.22); }
.fp-rose { background: rgb(239 68 68 / 0.09); color: #dc2626; box-shadow: inset 0 0 0 1px rgb(239 68 68 / 0.2); }
.fp-orange { background: rgb(249 115 22 / 0.12); color: #c2410c; box-shadow: inset 0 0 0 1px rgb(249 115 22 / 0.25); }
.fp-amber { background: rgb(245 158 11 / 0.12); color: #b45309; box-shadow: inset 0 0 0 1px rgb(245 158 11 / 0.25); }
/* --- user avatar tile in table */
.uavatar {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 10px;
font-size: 13px;
font-weight: 800;
flex-shrink: 0;
}
.fp-rose {
background: rgb(244 63 94 / 0.22);
color: rgb(253 164 175);
box-shadow: inset 0 0 0 1px rgb(253 164 175 / 0.45);
}
.fp-orange {
background: rgb(249 115 22 / 0.22);
color: rgb(253 186 116);
box-shadow: inset 0 0 0 1px rgb(253 186 116 / 0.45);
}
.fp-amber {
background: rgb(245 158 11 / 0.22);
color: rgb(252 211 77);
box-shadow: inset 0 0 0 1px rgb(252 211 77 / 0.45);
/* --- 操作列吸底右侧:横向滚动时按钮始终可见 */
.utable thead th:last-child,
.utable tbody td:last-child {
position: sticky;
right: 0;
box-shadow: -10px 0 14px -10px rgb(15 23 42 / 0.16);
}
.utable thead th:last-child { background: var(--surface-solid); border-bottom: 1px solid rgb(24 24 27 / 0.07); }
.utable tbody td:last-child { background: var(--surface-solid); }
/* 吸底格是定位元素,会盖住行分隔线——给它补同款底边框 */
.utable tbody tr:not(:last-child) td:last-child { box-shadow: -10px 0 14px -10px rgb(15 23 42 / 0.16), inset 0 -1px 0 var(--hairline); }
.utable tbody tr:hover td:last-child { background: #f7f7f8; }
.utable tbody tr.row-on td:last-child { background: #f1f1f3; }
/* --- icon-only action buttons */
.act {
display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem;
border-radius: 0.5rem;
color: rgb(255 255 255 / 0.7);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s, color 0.15s;
width: 1.9rem; height: 1.9rem; border-radius: 0.6rem;
color: var(--fg-3); background: var(--surface-solid);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
.act.danger {
color: rgb(253 164 175);
background: rgb(244 63 94 / 0.12);
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
}
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
.act:hover { background: var(--surface-2); color: var(--fg); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.14); }
.act.danger:hover { background: rgb(244 63 94 / 0.08); color: #dc2626; box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.28); }
/* disabled inputs in the edit modal — readable, but visually 'cool' so the
admin knows they can't change them. */
.field:disabled {
opacity: 0.65;
cursor: not-allowed;
background: rgb(255 255 255 / 0.025);
background: var(--surface-2);
}
/* iOS-style switch for the 状态 column — mirrors the one in ModelsView. */
@@ -638,8 +648,8 @@ async function doRecharge() {
position: relative;
width: 2.25rem; height: 1.3rem;
border-radius: 9999px;
background: rgb(255 255 255 / 0.12);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
background: var(--hover);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.18s ease;
}
.sw-thumb {
@@ -647,14 +657,11 @@ async function doRecharge() {
top: 2px; left: 2px;
width: calc(1.3rem - 4px); height: calc(1.3rem - 4px);
border-radius: 9999px;
background: white;
background: var(--surface-solid);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.3);
transition: transform 0.18s ease;
}
.sw-on {
background: rgb(16 185 129 / 0.7);
box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5);
}
.sw-on { background: #18181b; box-shadow: inset 0 0 0 1px #18181b; }
.sw-on .sw-thumb { transform: translateX(calc(2.25rem - 1.3rem)); }
/* --- numbered pagination buttons */
@@ -665,13 +672,13 @@ async function doRecharge() {
font-weight: 500;
text-align: center;
border-radius: 0.45rem;
color: rgb(255 255 255 / 0.7);
background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s;
}
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
.pg-on { background: var(--surface-solid); color: rgb(15 23 42); box-shadow: none; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }