changed botfather avatar and fixed import

This commit is contained in:
onysd 2026-07-24 00:54:19 +03:00
parent c436c7c773
commit 0a6c05404a
18 changed files with 318 additions and 108 deletions

View file

@ -837,6 +837,7 @@ type importDefaultStarGiftAPIRequest struct {
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
ID int `json:"id"`
Enabled bool `json:"enabled"`
}
func (s *server) handleImportDefaultStarGiftAPI(w http.ResponseWriter, r *http.Request) {
@ -851,6 +852,7 @@ func (s *server) handleImportDefaultStarGiftAPI(w http.ResponseWriter, r *http.R
req := admin.ImportDefaultStarGiftRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-default-gift"),
ID: body.ID,
Enabled: body.Enabled,
}
result, err := s.callAdminAPI(r.Context(), "/v1/default-gifts/import", req)
writeCommandResultAPI(w, result, err)
@ -860,6 +862,7 @@ type importAllDefaultStarGiftsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Enabled bool `json:"enabled"`
}
func (s *server) handleImportAllDefaultStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
@ -869,6 +872,7 @@ func (s *server) handleImportAllDefaultStarGiftsAPI(w http.ResponseWriter, r *ht
}
req := admin.ImportAllDefaultStarGiftsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-default-gifts"),
Enabled: body.Enabled,
}
result, err := s.callAdminAPI(r.Context(), "/v1/default-gifts/import-all", req)
writeCommandResultAPI(w, result, err)
@ -915,6 +919,7 @@ type importAllOfficialStarGiftsAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Enabled bool `json:"enabled"`
}
func (s *server) handleImportAllOfficialStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
@ -924,6 +929,7 @@ func (s *server) handleImportAllOfficialStarGiftsAPI(w http.ResponseWriter, r *h
}
req := admin.ImportAllOfficialStarGiftsRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "import-all-official-gifts"),
Enabled: body.Enabled,
}
result, err := s.callAdminAPI(r.Context(), "/v1/official-gifts/import-all", req)
writeCommandResultAPI(w, result, err)

View file

@ -110,13 +110,13 @@ func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
func TestDefaultGiftImportActionDecodes(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/actions/import-default-gift", strings.NewReader(`{
"command_id":"c1","reason":"demo","confirm":true,"id":3
"command_id":"c1","reason":"demo","confirm":true,"id":3,"enabled":true
}`))
var got importDefaultStarGiftAPIRequest
if err := decodeJSON(req, &got); err != nil {
t.Fatalf("decode default gift action: %v", err)
}
if got.ID != 3 || got.CommandID != "c1" || !got.Confirm {
if got.ID != 3 || got.CommandID != "c1" || !got.Confirm || !got.Enabled {
t.Fatalf("decoded default gift action = %+v", got)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo.png" />
<title>OwpenGram Admin</title>
<script type="module" crossorigin src="/assets/index-6aaLOsAb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D2rrhA4q.css">
<script type="module" crossorigin src="/assets/index-CBHSiG-t.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-cWo3_wIf.css">
</head>
<body>
<div id="root"></div>

View file

@ -273,6 +273,11 @@ const translations: Record<string, string> = {
"gifts.officialUnnamed": "Unnamed official gift #{id}",
"gifts.officialEmpty": "No official gifts match this category and search.",
"gifts.includeCollectible": "Import the complete collectible pool, including crafted models",
"gifts.importingProgress": "Importing {done} of {total}",
"gifts.bulkImportCount": "{count} gifts available to import",
"gifts.bulkImportDone": "Import complete",
"gifts.bulkImportSummary": "Imported {imported}, skipped {skipped}, failed {failed}",
"gifts.startBulkImport": "Start import",
"gifts.limited": "Limited · {total}",
"gifts.premium": "Premium only",
"gifts.searchPlaceholder": "Search gift ID, title or format",

View file

@ -2,7 +2,7 @@ import { CheckCircle2, ChevronLeft, ChevronRight, FileJson2, Gem, Loader2, Pause
import lottie from "lottie-web/build/player/lottie_light_canvas";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { api, APIError, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
@ -13,6 +13,11 @@ import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
type GiftPageSize = 10 | 20 | 50 | 100 | "all";
// The demo pool only has 3 placeholder gifts left after pruning to one per
// capability tier (Spark/Star/Coin); hide the tab until real custom designs
// replace them. Flip back to true to re-enable.
const SHOW_DEFAULT_GIFTS_TAB = false;
function defaultGiftAttributeCount(gift: DefaultGiftRow) {
return gift.model_count + gift.pattern_count + gift.backdrop_count;
}
@ -106,7 +111,7 @@ export function GiftsPage() {
const [importOpen, setImportOpen] = useState(false);
const [collectibleGift, setCollectibleGift] = useState<StarGiftRow | null>(null);
const [file, setFile] = useState<File | null>(null);
const [importSource, setImportSource] = useState<"default" | "official" | "file">("default");
const [importSource, setImportSource] = useState<"default" | "official" | "file">(SHOW_DEFAULT_GIFTS_TAB ? "default" : "official");
const [defaultGifts, setDefaultGifts] = useState<DefaultGiftRow[]>([]);
const [selectedDefaultID, setSelectedDefaultID] = useState(0);
const [officialGifts, setOfficialGifts] = useState<OfficialStarGiftRow[]>([]);
@ -128,6 +133,14 @@ export function GiftsPage() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [importError, setImportError] = useState("");
const [bulkImportOpen, setBulkImportOpen] = useState<"default" | "official" | null>(null);
const [bulkImportItems, setBulkImportItems] = useState<Array<DefaultGiftRow | OfficialStarGiftRow>>([]);
const [bulkImportEnabled, setBulkImportEnabled] = useState(true);
const [bulkImportReason, setBulkImportReason] = useState("");
const [bulkImportBusy, setBulkImportBusy] = useState(false);
const [bulkImportProgress, setBulkImportProgress] = useState({ done: 0, total: 0 });
const [bulkImportError, setBulkImportError] = useState("");
const [bulkImportResult, setBulkImportResult] = useState<{ imported: number; skipped: number; failed: number; errors: string[] } | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkReason, setBulkReason] = useState("");
const [bulkBusy, setBulkBusy] = useState(false);
@ -301,6 +314,84 @@ export function GiftsPage() {
setPreview(null);
}
async function openBulkImport(source: "default" | "official") {
setBulkImportOpen(source);
setBulkImportItems([]);
setBulkImportEnabled(true);
setBulkImportReason("");
setBulkImportBusy(false);
setBulkImportProgress({ done: 0, total: 0 });
setBulkImportError("");
setBulkImportResult(null);
try {
if (source === "default") {
const list = defaultGifts.length > 0 ? defaultGifts : (await api.defaultGifts()).gifts ?? [];
setBulkImportItems(list);
} else {
const list = officialGifts.length > 0 ? officialGifts : (await api.officialGifts()).gifts ?? [];
setBulkImportItems(list);
}
} catch (err) {
setBulkImportError(errorMessage(err));
}
}
function closeBulkImport() {
if (bulkImportBusy) return;
setBulkImportOpen(null);
}
async function runBulkImport() {
if (!bulkImportOpen) return;
if (!bulkImportReason.trim()) { setBulkImportError(t("action.reasonRequired")); return; }
const source = bulkImportOpen;
setBulkImportBusy(true); setBulkImportError(""); setBulkImportResult(null);
setBulkImportProgress({ done: 0, total: bulkImportItems.length });
let imported = 0, skipped = 0, failed = 0;
const errors: string[] = [];
for (const item of bulkImportItems) {
const label = source === "default" ? (item as DefaultGiftRow).title : ((item as OfficialStarGiftRow).title || `#${(item as OfficialStarGiftRow).source_gift_id}`);
try {
// Stable per-gift command_id (mirrors the old server-side bulk
// endpoint) so a gift already imported by a prior run is recognized
// as a replay instead of creating a duplicate catalog entry -
// CreateCatalogBundle has no unique constraint on title/source id to
// fall back on. If this run's Enabled value differs from the run
// that first created it, the server reports COMMAND_ID_CONFLICT
// instead of silently re-importing; treat that as "skipped" too.
const result = source === "default"
? await api.importDefaultGift({
command_id: `bulk-default-gift-${(item as DefaultGiftRow).id}`,
reason: bulkImportReason.trim(),
confirm: true,
id: (item as DefaultGiftRow).id,
enabled: bulkImportEnabled
})
: await api.importOfficialGift({
command_id: `bulk-official-gift-${(item as OfficialStarGiftRow).source_gift_id}`,
reason: bulkImportReason.trim(),
confirm: true,
source_gift_id: (item as OfficialStarGiftRow).source_gift_id,
include_collectible: (item as OfficialStarGiftRow).can_upgrade,
enabled: bulkImportEnabled
});
if (result.already_executed || result.details?.skipped) skipped++;
else imported++;
} catch (err) {
if (err instanceof APIError && err.message === "COMMAND_ID_CONFLICT") {
skipped++;
} else {
failed++;
errors.push(`${label}: ${errorMessage(err)}`);
}
}
setBulkImportProgress((prev) => ({ ...prev, done: prev.done + 1 }));
}
setBulkImportBusy(false);
setBulkImportResult({ imported, skipped, failed, errors });
await load();
}
async function validateImport() {
setBusy(true); setImportError(""); setPreview(null);
try {
@ -331,8 +422,10 @@ export function GiftsPage() {
function startImport() {
setGiftID("0"); setTitle(""); setStars("50"); setConvertStars("50"); setSortOrder("0");
setEnabled(true); setReason(""); setFile(null); setPreview(null); setImportError("");
setImportSource("default"); setSelectedDefaultID(0);
setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all"); setImportOpen(true);
setImportSource(SHOW_DEFAULT_GIFTS_TAB ? "default" : "official"); setSelectedDefaultID(0);
setSourceGiftID(""); setOfficialQuery(""); setOfficialCategory("all");
setBulkImportBusy(false); setBulkImportProgress({ done: 0, total: 0 }); setBulkImportError("");
setImportOpen(true);
}
function startRevision(gift: StarGiftRow) {
@ -434,22 +527,18 @@ export function GiftsPage() {
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div>
</div>
{giftID === "0" && <div className="gift-source-tabs">
<button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{t("gifts.defaultSource")}</button>
{SHOW_DEFAULT_GIFTS_TAB && <button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{t("gifts.defaultSource")}</button>}
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{t("gifts.officialSource")}</button>
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{t("gifts.fileSource")}</button>
</div>}
{importSource === "default" && giftID === "0" ? <section className="official-gift-picker">
{importSource === "default" && giftID === "0" && SHOW_DEFAULT_GIFTS_TAB ? <section className="official-gift-picker">
<div className="gift-import-note"><span>{t("gifts.defaultHint")}</span><div className="gift-format-chips"><span>{defaultGifts.length}</span><span>OwpenGram</span></div></div>
<div className="official-gift-bulk-import">
<ActionButton
label={t("gifts.importAllDefault")}
path="/api/actions/import-all-default-gifts"
payload={() => ({})}
tone="neutral"
icon={<Upload size={14} />}
onDone={() => void load()}
/>
<button className="btn" type="button" onClick={() => openBulkImport("default")}>
<Upload size={14} /> {t("gifts.importAllDefault")}
</button>
</div>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
<div className="official-gift-list" role="listbox" aria-label={t("gifts.defaultSelect")}>
{defaultGifts.map((gift) => {
const isSelected = gift.id === selectedDefaultID;
@ -479,14 +568,9 @@ export function GiftsPage() {
</section> : importSource === "official" && giftID === "0" ? <section className="official-gift-picker">
<div className="gift-import-note"><span>{t("gifts.officialHint")}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
<div className="official-gift-bulk-import">
<ActionButton
label={t("gifts.importAllOfficial")}
path="/api/actions/import-all-official-gifts"
payload={() => ({})}
tone="neutral"
icon={<Upload size={14} />}
onDone={() => void load()}
/>
<button className="btn" type="button" onClick={() => openBulkImport("official")}>
<Upload size={14} /> {t("gifts.importAllOfficial")}
</button>
</div>
<div className="official-gift-tools">
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={t("gifts.officialSearch")} /></label>
@ -569,6 +653,38 @@ export function GiftsPage() {
</div>,
document.body
)}
{bulkImportOpen && createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal gift-bulk-import-modal" role="dialog" aria-modal="true"
aria-label={bulkImportOpen === "default" ? t("gifts.importAllDefault") : t("gifts.importAllOfficial")}>
<div className="modal-head">
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{bulkImportOpen === "default" ? t("gifts.importAllDefault") : t("gifts.importAllOfficial")}</h2></div>
<button className="icon-btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy} aria-label={t("action.close")}><X size={15} /></button>
</div>
<div className="command-body">
<div className="gift-import-note"><span>{t("gifts.bulkImportCount", { count: bulkImportItems.length })}</span></div>
<label className="gift-switch"><input type="checkbox" checked={bulkImportEnabled} disabled={bulkImportBusy} onChange={(e) => setBulkImportEnabled(e.target.checked)} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label>
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={bulkImportReason} placeholder={t("gifts.reasonPlaceholder")} disabled={bulkImportBusy} onChange={(e) => setBulkImportReason(e.target.value)} /></label>
{bulkImportBusy && <div className="gift-bulk-import-progress">
<div className="gift-bulk-import-progress-bar"><div style={{ width: `${bulkImportProgress.total ? Math.round((bulkImportProgress.done / bulkImportProgress.total) * 100) : 0}%` }} /></div>
<span>{t("gifts.importingProgress", { done: bulkImportProgress.done, total: bulkImportProgress.total })}</span>
</div>}
{bulkImportError && <Alert>{bulkImportError}</Alert>}
{bulkImportResult && <div className="gift-validation">
<div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("gifts.bulkImportDone")}</strong><span>{t("gifts.bulkImportSummary", { imported: bulkImportResult.imported, skipped: bulkImportResult.skipped, failed: bulkImportResult.failed })}</span></div></div>
{bulkImportResult.errors.length > 0 && <pre>{bulkImportResult.errors.join("\n")}</pre>}
</div>}
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy}>{t("common.close")}</button>
<button className="btn primary" type="button" onClick={runBulkImport} disabled={bulkImportBusy || bulkImportItems.length === 0}>
{bulkImportBusy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />} {t("gifts.startBulkImport")}
</button>
</div>
</section>
</div>,
document.body
)}
{collectibleGift && <GiftCollectiblesModal gift={collectibleGift} onClose={() => setCollectibleGift(null)} onPublished={() => void load()} />}
</PageFrame>
);

View file

@ -275,9 +275,18 @@
.gift-list-summary { margin-left: auto; color: var(--muted); font-size: 11px; font-weight: 700; }
.gift-import-modal { width: min(860px, 100%); }
.gift-bulk-import-modal { width: min(480px, 100%); }
.gift-bulk-import-modal .command-body { display: grid; gap: 14px; padding: 16px 18px; }
.gift-import-modal-body { gap: 14px; }
.gift-source-tabs { display: flex; gap: 8px; }
.official-gift-picker { display: grid; min-width: 0; gap: 12px; }
.official-gift-bulk-import { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.gift-bulk-import-progress { display: flex; align-items: center; gap: 8px; min-width: 180px; }
.gift-bulk-import-progress-bar {
flex: 1 1 auto; width: 120px; height: 6px; overflow: hidden; background: #e3e8ef; border-radius: 999px;
}
.gift-bulk-import-progress-bar > div { height: 100%; background: var(--brand); border-radius: 999px; transition: width .2s ease; }
.gift-bulk-import-progress span { color: var(--muted); font-size: 11px; font-weight: 700; white-space: nowrap; }
.official-gift-tools { display: flex; align-items: center; gap: 12px; }
.official-gift-tools .searchbox { width: 100%; }
.official-gift-tools > span { flex: 0 0 auto; color: var(--muted); font-size: 11px; font-weight: 750; }

View file

@ -453,6 +453,11 @@ func run(logger *zap.Logger) error {
} else if seeded {
logger.Info("官方系统账号头像种子导入完成", zap.Int64("photo_id", domain.OfficialSystemUserPhotoID))
}
if seeded, err := filesService.SeedBotFatherAvatar(ctx); err != nil {
return fmt.Errorf("seed botfather avatar: %w", err)
} else if seeded {
logger.Info("BotFather 头像种子导入完成", zap.Int64("photo_id", domain.BotFatherUserPhotoID))
}
if stats, err := filesService.WarmCaches(ctx); err != nil {
logger.Warn("媒体资源缓存预热失败", zap.Error(err))
} else if stats.StickerSets > 0 || stats.Documents > 0 || stats.Blobs > 0 {