Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3b9e570c2 | ||
|
|
76510d34c9 | ||
|
|
1753e03898 | ||
|
|
d37ca69f77 | ||
|
|
0373aa2d03 | ||
|
|
e8e5527e63 | ||
|
|
58ee41a4f8 | ||
|
|
c60ba294e1 | ||
|
|
edd5253529 | ||
|
|
a9640ff059 | ||
|
|
e509584a77 | ||
|
|
ab9b63f5cb | ||
|
|
aeb94dd11c |
@@ -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.
|
||||||
|
|||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
|
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
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||