Compare commits

...
11 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
23 changed files with 222 additions and 107 deletions
+33
View File
@@ -20,10 +20,43 @@
[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>
---
## 🖼️ 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
**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/>
<img src="docs/screenshots/playground.png" alt="image2api — 画图台" width="860" />
<img src="docs/screenshots/home.png" alt="image2api — 首页" width="860" />
</div>
@@ -54,18 +54,28 @@
## 🖼️ 界面预览
<div align="center">
<sub>🎨 现代化画图前端 · 深 / 浅双主题管理后台 · 数据驱动的运营看板</sub>
<sub>✨ Vivid 风格浅色界面 · 渠道化调度 · 数据驱动的运营看板</sub>
</div>
<table>
<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/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>
</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>
</tr>
</table>
@@ -104,7 +114,7 @@
#### 🖥️ 用户前台(Vue 3)
- 画图台 · 创作记录画廊 · 生成日志(含失败原因 / 来源标签)
- 作品画廊**多选批量操作**:全选本页 / 批量删除 / 批量下载(自动打包 zip)
- **充值 · 订单** · API 文档 · API Key 管理 · 邀请 · 亮 / 暗主题
- **充值 · 订单** · API 文档 · API Key 管理 · 邀请 · 无限画布
- **站内公告**:登录后自动弹出 Markdown 公告
#### 🛠️ 管理后台
+5
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
}
+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.
// 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
}
+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
}
+19 -7
View File
@@ -299,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
}
@@ -374,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++
}
}
@@ -432,6 +440,9 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
if upID == "" {
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)
if alias == 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)
}
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) {
existing = nil
}
@@ -471,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)
}
}
@@ -501,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 {
+8 -8
View File
@@ -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.
+1 -1
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"`
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

+16 -5
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
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, #18181b, #27272a); color: #fff;
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); }
/* 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); }
@@ -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
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
@@ -337,7 +346,7 @@ 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: #fafafa;
background: transparent;
border-bottom: 1px solid rgb(24 24 27 / 0.07);
}
/* 表头统一:各页曾分别写 10px/11px、tracking 0.2em/0.04em——收拢到同一规格。 */
@@ -469,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: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;
+6 -6
View File
@@ -202,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: var(--fg-2); background: var(--surface-2);
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;
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.act:hover { background: var(--hover); color: var(--fg); }
.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);
+5 -12
View File
@@ -332,20 +332,13 @@ 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: var(--fg-2);
background: var(--surface-2);
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;
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.act:hover { background: var(--hover); color: var(--fg); }
.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;
+4 -5
View File
@@ -126,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 = []
@@ -324,9 +323,9 @@ onMounted(load)
.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:hover { background: var(--hover); color: var(--fg); }
.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; }
+6 -6
View File
@@ -137,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: var(--fg-2); background: var(--surface-2);
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;
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.act:hover { background: var(--hover); color: var(--fg); }
.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>
+6 -18
View File
@@ -273,25 +273,13 @@ onMounted(loadModels)
/* --- 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: var(--fg-2);
background: var(--surface-2);
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: var(--hover); color: var(--fg); }
.act.danger {
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);
}
.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>
+54 -23
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([])
@@ -267,21 +294,21 @@ async function doRecharge() {
<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-[1060px]">
<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-10" /> <!-- select -->
<col /> <!-- user (avatar+name+email+notes) -->
<col class="w-20" /> <!-- role -->
<col class="w-14" /> <!-- status switch -->
<col class="w-20" /> <!-- credits -->
<col class="w-24" /> <!-- recharge total -->
<col class="w-24" /> <!-- concurrency -->
<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-24" /> <!-- registered -->
<col class="w-36" /> <!-- last login + ip -->
<col class="w-28" /> <!-- actions -->
<col class="w-28" /> <!-- registered -->
<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]">
@@ -312,7 +339,7 @@ async function doRecharge() {
</td>
<td class="px-3 py-3.5 align-middle">
<div class="flex items-center gap-2.5 min-w-0">
<span class="uavatar">{{ (u.name || u.email || '?').slice(0, 1).toUpperCase() }}</span>
<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>
@@ -578,31 +605,35 @@ async function doRecharge() {
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--hairline);
color: var(--fg-2);
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 */
.act {
display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem;
border-radius: 0.5rem;
color: var(--fg-2);
background: var(--surface-2);
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;
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
}
.act:hover { background: var(--hover); color: var(--fg); }
.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. */