Compare commits

...
13 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
24 changed files with 269 additions and 149 deletions
+33
View File
@@ -20,10 +20,43 @@
[Live Demo](https://vividai.run) · [Features](#-features) · [Deploy](#-deployment) · [API](#-vivid-protocol-api) [Live Demo](https://vividai.run) · [Features](#-features) · [Deploy](#-deployment) · [API](#-vivid-protocol-api)
<br/>
<img src="docs/screenshots/home.png" alt="image2api — landing" width="860" />
</div> </div>
--- ---
## 🖼️ Screenshots
<div align="center">
<sub>✨ Vivid-style light UI · channel routing · data-driven ops dashboard</sub>
</div>
<table>
<tr>
<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>🎨 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="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>🔌 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 ## ✨ 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. **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.
+15 -5
View File
@@ -22,7 +22,7 @@
<br/> <br/>
<img src="docs/screenshots/playground.png" alt="image2api — 画图台" width="860" /> <img src="docs/screenshots/home.png" alt="image2api — 首页" width="860" />
</div> </div>
@@ -54,18 +54,28 @@
## 🖼️ 界面预览 ## 🖼️ 界面预览
<div align="center"> <div align="center">
<sub>🎨 现代化画图前端 · 深 / 浅双主题管理后台 · 数据驱动的运营看板</sub> <sub>✨ Vivid 风格浅色界面 · 渠道化调度 · 数据驱动的运营看板</sub>
</div> </div>
<table> <table>
<tr> <tr>
<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/dashboard.png" alt="概览看板" /></td>
<td width="33%"><img src="docs/screenshots/models.png" alt="模型管理" /></td> <td width="33%"><img src="docs/screenshots/models.png" alt="模型管理" /></td>
</tr>
<tr>
<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="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> <td width="33%"><img src="docs/screenshots/logs.png" alt="调用日志" /></td>
</tr> </tr>
<tr> <tr>
<td align="center"><b>📊 概览看板</b><br/><sub>用户 / 生成量 / 渠道健康 / 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>角色 / 并发组 / 积分 · 批量选中操作</sub></td>
<td align="center"><b>📜 调用日志</b><br/><sub>成功 / 失败 / 进行中 · 提示词与耗时全留痕</sub></td> <td align="center"><b>📜 调用日志</b><br/><sub>成功 / 失败 / 进行中 · 提示词与耗时全留痕</sub></td>
</tr> </tr>
</table> </table>
@@ -104,7 +114,7 @@
#### 🖥️ 用户前台(Vue 3) #### 🖥️ 用户前台(Vue 3)
- 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签) - 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签)
- 作品画廊**多选批量操作**:全选本页 / 批量删除 / 批量下载(自动打包 zip) - 作品画廊**多选批量操作**:全选本页 / 批量删除 / 批量下载(自动打包 zip)
- **充值 · 订单** · API 文档 · API Key 管理 · 邀请 · 亮 / 暗主题 - **充值 · 订单** · API 文档 · API Key 管理 · 邀请 · 无限画布
- **站内公告**:登录后自动弹出 Markdown 公告 - **站内公告**:登录后自动弹出 Markdown 公告
#### 🛠️ 管理后台 #### 🛠️ 管理后台
+5
View File
@@ -5,6 +5,7 @@ import (
"time" "time"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm"
) )
type User struct { type User struct {
@@ -160,6 +161,10 @@ type ModelConfig struct {
// successful generation. Independent of the event_log (which is subject to // successful generation. Independent of the event_log (which is subject to
// retention / manual clearing), so the admin "次数" is a true running total. // retention / manual clearing), so the admin "次数" is a true running total.
GenerationCount int64 `gorm:"not null;default:0"` 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 CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
+2 -1
View File
@@ -212,13 +212,14 @@ func (r *ChannelRepository) ChannelIDsByModel(ctx context.Context, modelID strin
} }
// OrphanModelIDs returns local model ids that no channel binds anymore. // 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) { func (r *ChannelRepository) OrphanModelIDs(ctx context.Context) ([]string, error) {
var ids []string var ids []string
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("model_configs"). Table("model_configs").
Select("model_configs.id"). Select("model_configs.id").
Joins("LEFT JOIN channel_models ON channel_models.model_id = 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 Scan(&ids).Error
return ids, err return ids, err
} }
+34 -2
View File
@@ -44,6 +44,16 @@ func (r *ModelRepository) Get(ctx context.Context, modelID string) (*model.Model
return &item, nil 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) { func (r *ModelRepository) NameMap(ctx context.Context) (map[string]string, error) {
items, err := r.List(ctx) items, err := r.List(ctx)
if err != nil { 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) { 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() 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 return nil, err
} }
var item model.ModelConfig 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 nil, err
} }
return &item, nil 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) { func (r *ModelRepository) Delete(ctx context.Context, modelID string) (int64, error) {
res := r.db.WithContext(ctx).Delete(&model.ModelConfig{}, "id = ?", modelID) res := r.db.WithContext(ctx).Delete(&model.ModelConfig{}, "id = ?", modelID)
return res.RowsAffected, res.Error 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
}
+19 -7
View File
@@ -299,6 +299,13 @@ func normalizeRemoteModel(m upstream.RemoteModel) (ChannelModelSpec, error) {
default: default:
return spec, fmt.Errorf("未知类型 %q", spec.Kind) 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 return spec, nil
} }
@@ -374,11 +381,12 @@ func (s *ChannelService) PurgeOrphanModels(ctx context.Context) (int, error) {
} }
n := 0 n := 0
for _, id := range ids { 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" { if gerr != nil || m == nil || m.Provider == "channel" {
continue continue
} }
if _, derr := s.models.Delete(ctx, id); derr == nil { // provider 时代残留行没有复活路径,直接物理删除,不留软删垃圾。
if _, derr := s.models.DeleteHard(ctx, id); derr == nil {
n++ n++
} }
} }
@@ -432,6 +440,9 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
if upID == "" { if upID == "" {
continue continue
} }
if spec.Duration != nil && spec.Duration.Mode != "options" && spec.Duration.Mode != "range" {
return fmt.Errorf("模型 %s:时长模式仅支持 options / range", upID)
}
alias := strings.TrimSpace(spec.Alias) alias := strings.TrimSpace(spec.Alias)
if alias == upID { if alias == upID {
return fmt.Errorf("「%s」别名不能与上游模型同名,留空即可", upID) return fmt.Errorf("「%s」别名不能与上游模型同名,留空即可", upID)
@@ -444,7 +455,8 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
return fmt.Errorf("别名 %s 被多个上游模型复用", localID) return fmt.Errorf("别名 %s 被多个上游模型复用", localID)
} }
seenAliases[localID] = upID seenAliases[localID] = upID
existing, gerr := s.models.Get(ctx, localID) // GetAny: 软删行也纳入——重导同一模型时复活原行,计数/定价不丢。
existing, gerr := s.models.GetAny(ctx, localID)
if gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound) { if gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound) {
existing = nil existing = nil
} }
@@ -471,7 +483,10 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
return fmt.Errorf("model %s: %w", localID, err) return fmt.Errorf("model %s: %w", localID, err)
} }
} else { } 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) return fmt.Errorf("model %s: %w", localID, err)
} }
} }
@@ -501,9 +516,6 @@ func modelPatchFromSpec(spec ChannelModelSpec, existing *model.ModelConfig) map[
} }
if d := spec.Duration; d != nil { if d := spec.Duration; d != nil {
switch d.Mode { 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": case "options":
opts := make([]string, 0, len(d.Options)) opts := make([]string, 0, len(d.Options))
for _, s := range d.Options { for _, s := range d.Options {
+8 -8
View File
@@ -342,8 +342,9 @@ type V1ModelItem struct {
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"` RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
} }
// V1ModelDur exposes a video model's duration capability: fixed=locked seconds, // V1ModelDur exposes a video model's duration capability: options=discrete
// options=discrete list, range=continuous interval. // list (single-choice models too — "fixed" is never emitted), range=continuous
// interval.
type V1ModelDur struct { type V1ModelDur struct {
Mode string `json:"mode"` Mode string `json:"mode"`
Seconds int64 `json:"seconds,omitempty"` 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)} return &V1ModelDur{Mode: "range", Min: int64(item.DurationMin), Max: int64(item.DurationMax)}
} }
opts := repo.JSONStrings(item.Durations) opts := repo.JSONStrings(item.Durations)
switch len(opts) { if len(opts) == 0 {
case 0:
return nil return nil
case 1: }
return &V1ModelDur{Mode: "fixed", Seconds: int64(parseDurationSeconds(opts[0]))}
default:
vals := make([]int64, 0, len(opts)) vals := make([]int64, 0, len(opts))
for _, d := range opts { for _, d := range opts {
if n := parseDurationSeconds(d); n > 0 { if n := parseDurationSeconds(d); n > 0 {
vals = append(vals, int64(n)) 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. // UserBalance — GET /v1/balance. Re-reads the user row so the number is live.
+1 -1
View File
@@ -102,7 +102,7 @@ type RemoteModel struct {
// RemoteDuration mirrors the duration spec of a vivid model item. // RemoteDuration mirrors the duration spec of a vivid model item.
type RemoteDuration struct { type RemoteDuration struct {
Mode string `json:"mode"` // fixed | options | range Mode string `json:"mode"` // options | range
Seconds int64 `json:"seconds,omitempty"` Seconds int64 `json:"seconds,omitempty"`
Min int64 `json:"min,omitempty"` Min int64 `json:"min,omitempty"`
Max int64 `json:"max,omitempty"` Max int64 `json:"max,omitempty"`
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

+24 -12
View File
@@ -139,12 +139,19 @@ 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 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 the scoped `.x[data-v-*]` rules. Dark mode keeps the scoped look (gated on
:not(.dark)). */ :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); 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); 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) { html:not(.dark) .theme-text :is(.fp-on, .pg-on, .seg-on, .opt-on) {
background: linear-gradient(120deg, #18181b, #27272a); color: #fff; background: linear-gradient(120deg, #18181b, #27272a); color: #fff;
box-shadow: 0 4px 16px var(--accent-glow); box-shadow: 0 4px 16px var(--accent-glow);
@@ -264,8 +271,8 @@ html:not(.dark) .theme-text .lbl { color: var(--fg); }
html:not(.dark) .theme-text :is(.hint, .flbl) { color: var(--fg-3); } 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 /* Delete icon button: a clean neutral button with a red glyph (not a heavy pink
box), filling solid red on hover. */ 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 { 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: #fff; background: rgb(244 63 94); box-shadow: inset 0 0 0 1px rgb(225 29 72); } 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 /* Soft "danger" toolbar buttons (删除选中 / 删除异常账号 …): a muted red, not a
heavy pink fill; solid red on hover. Outranks the per-view scoped pink. */ 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); } 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); }
@@ -328,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 columns, let the card scroll horizontally. overflow-y is left to the card's
own utility so vertical clipping / rounded corners are preserved. */ own utility so vertical clipping / rounded corners are preserved. */
.card:has(> table) { overflow-x: auto; } .card:has(> table) { overflow-x: auto; }
/* 后台卡片统一纯白——90% 半透明白叠在米色页底上会发灰 */
.theme-text .card { background: var(--surface-solid); }
/* ===== Admin table polish ===== /* ===== Admin table polish =====
Every data table lives in a `.card` and views hand-roll their own Every data table lives in a `.card` and views hand-roll their own
@@ -337,17 +346,18 @@ button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: poi
over-media tables are untouched; specificity outranks the per-view over-media tables are untouched; specificity outranks the per-view
Tailwind hover utilities and the light-mode white rescue. */ Tailwind hover utilities and the light-mode white rescue. */
.theme-text .card table thead { .theme-text .card table thead {
background: #fafafa; background: transparent;
border-bottom: 1px solid rgb(24 24 27 / 0.07); border-bottom: 1px solid rgb(24 24 27 / 0.07);
} }
/* 表头统一:各页曾分别写 10px/11px、tracking 0.2em/0.04em——收拢到同一规格。 */ /* 表头统一:各页曾分别写 10px/11px、tracking 0.2em/0.04em——收拢到同一规格。 */
.theme-text .card table thead :is(th, td) { .theme-text .card table thead :is(th, td) {
font-size: 11px; font-size: 13px;
font-weight: 700; font-weight: 650;
letter-spacing: 0.07em; letter-spacing: 0.01em;
color: var(--fg-3); color: var(--fg-3);
padding-top: 11px; padding-top: 16px;
padding-bottom: 11px; padding-bottom: 16px;
line-height: 1.4;
white-space: nowrap; white-space: nowrap;
} }
.theme-text .card table tbody tr { transition: background-color 0.12s ease; } .theme-text .card table tbody tr { transition: background-color 0.12s ease; }
@@ -356,8 +366,8 @@ button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: poi
/* 多选表格的选中行高亮(行加 .row-on) */ /* 多选表格的选中行高亮(行加 .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 { background-color: rgb(24 24 27 / 0.035); }
.theme-text .card table tbody tr.row-on:hover { background-color: #f0f0f2; } .theme-text .card table tbody tr.row-on:hover { background-color: #f0f0f2; }
/* 表体单元格基线:没显式设颜色的 td 统一次要色,数字/名称列各自覆盖 */ /* 表体单元格基线:统一 13px 字号 + 次要色,数字/名称列各自覆盖 */
.theme-text .card table tbody td { color: var(--fg-2); } .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; } .theme-text .card table tbody tr.row-on td:first-child { box-shadow: inset 2px 0 0 #18181b; }
/* ── 空占位:图标块 + 文案,全站表格/卡片共用 ── */ /* ── 空占位:图标块 + 文案,全站表格/卡片共用 ── */
@@ -468,6 +478,8 @@ button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: poi
} }
.btn-primary:hover:not(:disabled) { background: #27272a; box-shadow: 0 10px 22px -8px rgb(9 9 11 / 0.5); } .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; } .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 { .btn-soft {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-semibold @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; disabled:opacity-50 disabled:cursor-not-allowed px-3 py-1.5;
+6 -6
View File
@@ -202,14 +202,14 @@ onMounted(load)
<style scoped> <style scoped>
.act { .act {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem; border-radius: 0.5rem; width: 1.9rem; height: 1.9rem; border-radius: 0.6rem;
color: var(--fg-2); background: var(--surface-2); color: var(--fg-3); background: var(--surface-solid);
box-shadow: inset 0 0 0 1px var(--hairline); box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s, box-shadow 0.15s;
} }
.act:hover { background: var(--hover); color: var(--fg); } .act:hover { background: var(--surface-2); color: var(--fg); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.14); }
.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 { background: rgb(244 63 94 / 0.08); color: #dc2626; box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.28); }
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
.btn-soft.danger { .btn-soft.danger {
color: rgb(253 164 175); color: rgb(253 164 175);
background: rgb(244 63 94 / 0.12); background: rgb(244 63 94 / 0.12);
+5 -12
View File
@@ -332,20 +332,13 @@ const pageNumbers = computed(() => {
/* row icon action buttons — mirror 账号管理 for a consistent look */ /* row icon action buttons — mirror 账号管理 for a consistent look */
.act { .act {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem; width: 1.9rem; height: 1.9rem; border-radius: 0.6rem;
border-radius: 0.5rem; color: var(--fg-3); background: var(--surface-solid);
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline); box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s, box-shadow 0.15s;
} }
.act:hover { background: var(--hover); color: var(--fg); } .act:hover { background: var(--surface-2); color: var(--fg); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.14); }
.act.danger { .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); }
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); }
.fp { .fp {
display: inline-flex; align-items: center; gap: 0.35rem; display: inline-flex; align-items: center; gap: 0.35rem;
+4 -5
View File
@@ -126,8 +126,7 @@ function specText(s) {
const parts = [] const parts = []
if (s.qualities?.length) parts.push(s.qualities.join('/')) if (s.qualities?.length) parts.push(s.qualities.join('/'))
if (s.duration) { if (s.duration) {
if (s.duration.mode === 'fixed') parts.push(`固定 ${s.duration.seconds}s`) if (s.duration.mode === 'range') parts.push(`${s.duration.min}-${s.duration.max}s`)
else 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('/')) else if (s.duration.mode === 'options') parts.push((s.duration.options || []).map((d) => `${d}s`).join('/'))
} }
const refs = [] const refs = []
@@ -324,9 +323,9 @@ onMounted(load)
.lbl { display: block; font-size: 0.72rem; font-weight: 500; color: var(--fg-3); 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: var(--surface-2); color: var(--fg-2); } .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(--hover); color: var(--fg); } .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.15); color: rgb(251 113 133); } .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 */ /* 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 { 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; }
+6 -6
View File
@@ -137,14 +137,14 @@ onMounted(load)
<style scoped> <style scoped>
.act { .act {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem; border-radius: 0.5rem; width: 1.9rem; height: 1.9rem; border-radius: 0.6rem;
color: var(--fg-2); background: var(--surface-2); color: var(--fg-3); background: var(--surface-solid);
box-shadow: inset 0 0 0 1px var(--hairline); box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s, box-shadow 0.15s;
} }
.act:hover { background: var(--hover); color: var(--fg); } .act:hover { background: var(--surface-2); color: var(--fg); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.14); }
.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 { background: rgb(244 63 94 / 0.08); color: #dc2626; box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.28); }
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; } .fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; } .fade-enter-from, .fade-leave-to { opacity: 0; }
</style> </style>
+6 -18
View File
@@ -273,25 +273,13 @@ onMounted(loadModels)
/* --- compact square icon button for the actions column --- */ /* --- compact square icon button for the actions column --- */
.act { .act {
display: inline-flex; display: inline-flex; align-items: center; justify-content: center;
align-items: center; width: 1.9rem; height: 1.9rem; border-radius: 0.6rem;
justify-content: center; color: var(--fg-3); background: var(--surface-solid);
width: 1.9rem;
height: 1.9rem;
border-radius: 0.5rem;
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline); box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s, box-shadow 0.15s; transition: background 0.15s, color 0.15s, box-shadow 0.15s;
} }
.act:hover { background: var(--hover); color: var(--fg); } .act:hover { background: var(--surface-2); color: var(--fg); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.14); }
.act.danger { .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); }
color: rgb(225 29 72);
background: rgb(244 63 94 / 0.08);
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.22);
}
.act.danger:hover {
color: #fff;
background: rgb(225 29 72);
}
</style> </style>
+2 -2
View File
@@ -416,8 +416,8 @@ async function recharge() {
box-shadow: inset 0 0 0 1px var(--hairline); box-shadow: inset 0 0 0 1px var(--hairline);
color: var(--fg-2); color: var(--fg-2);
} }
.st-label { font-size: 11px; font-weight: 700; letter-spacing: .05em; color: var(--fg-faint); margin-bottom: 4px; } .st-label { font-size: 11px; font-weight: 700; letter-spacing: .05em; color: var(--fg-faint); margin-bottom: 9px; }
.st-value { font-size: 14px; font-weight: 600; color: var(--fg); } .st-value { font-size: 14.5px; font-weight: 600; color: var(--fg); line-height: 1.5; }
.st-num { font-variant-numeric: tabular-nums; } .st-num { font-variant-numeric: tabular-nums; }
.key-code { background: var(--surface-2); padding: 1px 6px; border-radius: 5px; font-size: 12px; } .key-code { background: var(--surface-2); padding: 1px 6px; border-radius: 5px; font-size: 12px; }
.key-box { .key-box {
+91 -56
View File
@@ -38,6 +38,33 @@ const ROLE_OPTIONS = [
] ]
const roleLabel = (r) => ({ user: '用户', agent: '代理', admin: '管理员' }[r] || '用户') 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, // Concurrency groups — resolve a user's group id → name/limit for the table,
// and offer them in the edit form. // and offer them in the edit form.
const cgroups = ref([]) const cgroups = ref([])
@@ -267,24 +294,21 @@ async function doRecharge() {
<button v-if="!stats.total" @click="showAdd = true" class="btn-soft">新建第一个</button> <button v-if="!stats.total" @click="showAdd = true" class="btn-soft">新建第一个</button>
</div> </div>
<div v-else class="overflow-x-auto"> <div v-else class="overflow-x-auto" @wheel="onTableWheel">
<table class="w-full text-sm table-fixed min-w-[1500px]"> <table class="utable w-full text-sm table-fixed min-w-[1200px]">
<colgroup> <colgroup>
<col class="w-9" /> <!-- select --> <col class="w-10" /> <!-- select -->
<col class="w-36" /> <!-- username --> <col /> <!-- user (avatar+name+email+notes) -->
<col /> <!-- email (flex) -->
<col class="w-24" /> <!-- notes -->
<col class="w-28" /> <!-- concurrency -->
<col class="w-20" /> <!-- role --> <col class="w-20" /> <!-- role -->
<col class="w-16" /> <!-- status switch --> <col class="w-14" /> <!-- status switch -->
<col class="w-24" /> <!-- credits --> <col class="w-20" /> <!-- credits -->
<col class="w-24" /> <!-- recharge total --> <col class="w-20" /> <!-- recharge total -->
<col class="w-20" /> <!-- generation count --> <col class="w-28" /> <!-- concurrency -->
<col class="w-20" /> <!-- banned word hits --> <col class="w-16" /> <!-- generation count -->
<col class="w-16" /> <!-- banned word hits -->
<col class="w-28" /> <!-- registered --> <col class="w-28" /> <!-- registered -->
<col class="w-28" /> <!-- last login --> <col class="w-40" /> <!-- last login + ip -->
<col class="w-32" /> <!-- login IP --> <col class="w-36" /> <!-- actions -->
<col class="w-32" /> <!-- actions -->
</colgroup> </colgroup>
<thead> <thead>
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]"> <tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
@@ -292,19 +316,16 @@ async function doRecharge() {
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll" <input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
class="chk" title="全选" /> class="chk" title="全选" />
</th> </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-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-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-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">最近登录</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> <th class="text-right px-3 py-3 font-medium">操作</th>
</tr> </tr>
</thead> </thead>
@@ -316,17 +337,15 @@ async function doRecharge() {
<input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" @click.stop <input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" @click.stop
class="chk" /> class="chk" />
</td> </td>
<td class="px-3 py-3.5 align-middle text-sm font-medium text-white/90 truncate" :title="u.name || '—'"> <td class="px-3 py-3.5 align-middle">
{{ u.name || '—' }} <div class="flex items-center gap-2.5 min-w-0">
</td> <span class="uavatar" :style="avatarStyle(u)">{{ (u.name || u.email || '?').slice(0, 1).toUpperCase() }}</span>
<td class="px-3 py-3.5 align-middle text-xs text-white/75 truncate" :title="u.email"> <div class="min-w-0">
{{ u.email || '—' }} <div class="text-[13.5px] font-semibold text-white/90 truncate" :title="u.name || '—'">{{ u.name || '—' }}</div>
</td> <div class="text-xs text-white/50 truncate" :title="u.email">{{ u.email || '—' }}</div>
<td class="px-3 py-3.5 align-middle text-xs truncate" :class="u.notes ? 'text-white/70' : 'text-white/25'" :title="u.notes || ''"> <div v-if="u.notes" class="text-[11px] text-white/35 truncate" :title="u.notes">{{ u.notes }}</div>
{{ u.notes || '—' }} </div>
</td> </div>
<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> </td>
<td class="px-3 py-3.5 align-middle"> <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" <span class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 whitespace-nowrap"
@@ -355,6 +374,9 @@ async function doRecharge() {
:class="u.recharge_total > 0 ? 'text-emerald-600' : 'text-white/25'"> :class="u.recharge_total > 0 ? 'text-emerald-600' : 'text-white/25'">
¥{{ (u.recharge_total || 0).toLocaleString('en-US') }} ¥{{ (u.recharge_total || 0).toLocaleString('en-US') }}
</td> </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" <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'"> :class="u.generation_count > 0 ? 'text-white/85' : 'text-white/25'">
{{ (u.generation_count || 0).toLocaleString('en-US') }} {{ (u.generation_count || 0).toLocaleString('en-US') }}
@@ -363,22 +385,16 @@ async function doRecharge() {
:class="u.banned_word_hits > 0 ? 'text-rose-600' : 'text-white/25'"> :class="u.banned_word_hits > 0 ? 'text-rose-600' : 'text-white/25'">
{{ (u.banned_word_hits || 0).toLocaleString('en-US') }} {{ (u.banned_word_hits || 0).toLocaleString('en-US') }}
</td> </td>
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap"> <td class="px-3 py-3.5 align-middle whitespace-nowrap">
<div v-if="u.created_at" class="leading-tight" :title="fmtTs(u.created_at)"> <span v-if="u.created_at" class="text-[13px] text-white/65 tabular-nums" :title="fmtTs(u.created_at)">{{ fmtDate(u.created_at) }}</span>
<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>
<span v-else class="text-white/25">—</span> <span v-else class="text-white/25">—</span>
</td> </td>
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap"> <td class="px-3 py-3.5 align-middle whitespace-nowrap">
<div v-if="u.last_login_at" class="leading-tight" :title="fmtTs(u.last_login_at)"> <div v-if="u.last_login_at" class="leading-snug" :title="fmtTs(u.last_login_at)">
<div class="text-white/65 tabular-nums">{{ fmtDate(u.last_login_at) }}</div> <div class="text-[13px] text-white/65 tabular-nums">{{ fmtDate(u.last_login_at) }} {{ fmtClock(u.last_login_at) }}</div>
<div class="text-white/35 tabular-nums">{{ 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> </div>
<span v-else class="text-white/25">从未登录</span> <span v-else class="text-[13px] 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 || '—' }}
</td> </td>
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap"> <td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
<div class="inline-flex items-center gap-1"> <div class="inline-flex items-center gap-1">
@@ -582,23 +598,42 @@ async function doRecharge() {
.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-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); } .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;
}
/* --- 操作列吸底右侧:横向滚动时按钮始终可见 */
.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 */ /* --- icon-only action buttons */
.act { .act {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem; width: 1.9rem; height: 1.9rem; border-radius: 0.6rem;
border-radius: 0.5rem; color: var(--fg-3); background: var(--surface-solid);
color: var(--fg-2);
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline); box-shadow: inset 0 0 0 1px var(--hairline);
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s, box-shadow 0.15s;
} }
.act:hover { background: var(--hover); color: var(--fg); } .act:hover { background: var(--surface-2); color: var(--fg); box-shadow: inset 0 0 0 1px rgb(24 24 27 / 0.14); }
.act.danger { .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); }
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); }
/* disabled inputs in the edit modal — readable, but visually 'cool' so the /* disabled inputs in the edit modal — readable, but visually 'cool' so the
admin knows they can't change them. */ admin knows they can't change them. */