Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3b9e570c2 | ||
|
|
76510d34c9 | ||
|
|
1753e03898 | ||
|
|
d37ca69f77 | ||
|
|
0373aa2d03 | ||
|
|
e8e5527e63 | ||
|
|
58ee41a4f8 | ||
|
|
c60ba294e1 | ||
|
|
edd5253529 | ||
|
|
a9640ff059 | ||
|
|
e509584a77 |
@@ -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.
|
||||
|
||||
@@ -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,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
@@ -160,8 +161,12 @@ 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"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// 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
|
||||
}
|
||||
|
||||
func (m ModelConfig) EffectiveName() string {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
vals := make([]int64, 0, len(opts))
|
||||
for _, d := range opts {
|
||||
if n := parseDurationSeconds(d); n > 0 {
|
||||
vals = append(vals, int64(n))
|
||||
}
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
Before Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 383 KiB After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 672 KiB |
|
Before Width: | Height: | Size: 553 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 460 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 140 KiB |
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. */
|
||||
|
||||