feat: 实现 portal MVP-1 用户界面 (#26)
This commit is contained in:
@@ -176,7 +176,7 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) {
|
||||
t.Fatalf("login=%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
emptyWorkspace := clientA.do(http.MethodGet, "/", nil, "")
|
||||
if emptyWorkspace.Code != http.StatusOK || !strings.Contains(emptyWorkspace.Body.String(), "还没有生成记录") || !strings.Contains(emptyWorkspace.Body.String(), "开始第一次生成") {
|
||||
if emptyWorkspace.Code != http.StatusOK || !strings.Contains(emptyWorkspace.Body.String(), "还没有生成记录") || !strings.Contains(emptyWorkspace.Body.String(), "文生图") || !strings.Contains(emptyWorkspace.Body.String(), "图片编辑") {
|
||||
t.Fatalf("empty workspace=%d %s", emptyWorkspace.Code, emptyWorkspace.Body.String())
|
||||
}
|
||||
oldRequest := httptest.NewRequest(http.MethodGet, "/api/generations", nil)
|
||||
|
||||
@@ -479,7 +479,8 @@ func (h *Handler) appPage(c *gin.Context) {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
history, err := h.service.RecentHistory(c.Request.Context(), state.UserID)
|
||||
historyLimit := h.service.DefaultHistoryLimit()
|
||||
history, err := h.service.History(c.Request.Context(), state.UserID, historyLimit, "")
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
@@ -499,7 +500,7 @@ func (h *Handler) appPage(c *gin.Context) {
|
||||
current = &detail
|
||||
}
|
||||
maxPromptBytes, maxImages := h.service.Limits()
|
||||
page := web.Page{Title: "生成工作台", DisplayName: user.DisplayName, CSRFToken: state.CSRFToken, History: history, Current: current, MaxPromptBytes: maxPromptBytes, MaxImages: maxImages}
|
||||
page := web.Page{Title: "生成工作台", DisplayName: user.DisplayName, CSRFToken: state.CSRFToken, History: history.Items, HistoryCursor: history.NextCursor, HistoryHasMore: history.HasMore, HistoryLimit: historyLimit, Current: current, MaxPromptBytes: maxPromptBytes, MaxImages: maxImages}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.renderer.Render(c.Writer, "app", page); err != nil {
|
||||
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"image/png"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
||||
passwordpkg "git.ilapage.cn/OPC/chorus/internal/platform/password"
|
||||
platformstorage "git.ilapage.cn/OPC/chorus/internal/platform/storage"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
@@ -28,7 +30,7 @@ func main() {
|
||||
|
||||
func run() error {
|
||||
if len(os.Args) != 4 {
|
||||
return fmt.Errorf("usage: fixture <running|text-success|image-success|failed> <generation-id> <user-email>")
|
||||
return fmt.Errorf("usage: fixture <setup|cleanup|running|text-success|image-success|failed> <generation-id> <user-email>")
|
||||
}
|
||||
dsn := os.Getenv("CHORUS_E2E_DSN")
|
||||
if dsn == "" {
|
||||
@@ -49,6 +51,10 @@ func run() error {
|
||||
defer sqlDB.Close()
|
||||
|
||||
switch os.Args[1] {
|
||||
case "setup":
|
||||
return setup(db, os.Args[3], os.Getenv("CHORUS_E2E_PASSWORD"))
|
||||
case "cleanup":
|
||||
return cleanup(db, os.Args[3])
|
||||
case "running":
|
||||
return setRunning(db, generationID, os.Args[3])
|
||||
case "text-success":
|
||||
@@ -62,6 +68,99 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
func setup(db *gorm.DB, email, password string) error {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" || password == "" {
|
||||
return fmt.Errorf("e2e credentials are required")
|
||||
}
|
||||
passwordHash, err := passwordpkg.Encode(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode e2e password")
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`INSERT INTO users (email,password_hash,display_name,status) VALUES (?,?,?,'active') ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash),display_name=VALUES(display_name),status='active'`, email, passwordHash, "Portal E2E User").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`INSERT INTO providers (slug,name,base_url,auth_type,enabled) VALUES ('portal-e2e','Portal E2E','https://example.invalid','none',TRUE) ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id),name=VALUES(name),base_url=VALUES(base_url),auth_type='none',enabled=TRUE`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var providerID uint64
|
||||
if err := tx.Raw("SELECT id FROM providers WHERE slug='portal-e2e'").Scan(&providerID).Error; err != nil || providerID == 0 {
|
||||
return fmt.Errorf("load e2e provider")
|
||||
}
|
||||
for _, route := range []struct {
|
||||
capability model.Capability
|
||||
apiType model.APIType
|
||||
}{
|
||||
{model.CapabilityImageGenerate, model.APIImages},
|
||||
{model.CapabilityImageEdit, model.APIImagesEdits},
|
||||
} {
|
||||
key := "portal-e2e-" + string(route.capability)
|
||||
if err := tx.Exec(`INSERT INTO prompt_templates (template_key,kind,api_type,capability,name,version,template_text,default_role_rule,enabled) VALUES (?,'image',?,?,?,1,'{{.UserPrompt}}','Use the primary image as the subject and reference images only as supporting context.',TRUE) ON DUPLICATE KEY UPDATE kind='image',api_type=VALUES(api_type),capability=VALUES(capability),name=VALUES(name),version=1,template_text=VALUES(template_text),default_role_rule=VALUES(default_role_rule),enabled=TRUE`, key, route.apiType, route.capability, "Portal E2E "+string(route.capability)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var templateID uint64
|
||||
if err := tx.Raw("SELECT id FROM prompt_templates WHERE template_key=?", key).Scan(&templateID).Error; err != nil || templateID == 0 {
|
||||
return fmt.Errorf("load e2e prompt template")
|
||||
}
|
||||
modelID := "portal-e2e-" + string(route.capability)
|
||||
if err := tx.Exec(`INSERT INTO provider_models (provider_id,name,model_id,api_type,kind,extra_body,timeout_ms,weight,enabled) VALUES (?,?,?,?, 'image',JSON_OBJECT(),1000,100,TRUE) ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id),name=VALUES(name),kind='image',extra_body=JSON_OBJECT(),timeout_ms=1000,weight=100,enabled=TRUE`, providerID, "Portal E2E Model", modelID, route.apiType).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var providerModelID uint64
|
||||
if err := tx.Raw("SELECT id FROM provider_models WHERE provider_id=? AND model_id=? AND api_type=?", providerID, modelID, route.apiType).Scan(&providerModelID).Error; err != nil || providerModelID == 0 {
|
||||
return fmt.Errorf("load e2e provider model")
|
||||
}
|
||||
if err := tx.Exec("INSERT IGNORE INTO provider_model_capabilities (provider_model_id,capability) VALUES (?,?)", providerModelID, route.capability).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slug := "portal-e2e-" + string(route.capability)
|
||||
if err := tx.Exec(`INSERT INTO route_pools (slug,name,capability,prompt_template_id,max_failover,version,enabled) VALUES (?,?,?,?,0,1,TRUE) ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id),name=VALUES(name),capability=VALUES(capability),prompt_template_id=VALUES(prompt_template_id),max_failover=0,version=1,enabled=TRUE`, slug, "Portal E2E Route", route.capability, templateID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var routeID uint64
|
||||
if err := tx.Raw("SELECT id FROM route_pools WHERE slug=?", slug).Scan(&routeID).Error; err != nil || routeID == 0 {
|
||||
return fmt.Errorf("load e2e route")
|
||||
}
|
||||
if err := tx.Exec(`INSERT INTO route_pool_members (route_pool_id,provider_model_id,weight,failure_threshold,open_seconds,half_open_max,enabled,position) VALUES (?,?,100,3,60,1,TRUE,0) ON DUPLICATE KEY UPDATE weight=100,failure_threshold=3,open_seconds=60,half_open_max=1,enabled=TRUE,position=0`, routeID, providerModelID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec(`INSERT IGNORE INTO active_routes (capability,route_pool_id) VALUES (?,?)`, route.capability, routeID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func cleanup(db *gorm.DB, email string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var userID uint64
|
||||
_ = tx.Raw("SELECT id FROM users WHERE email=?", strings.ToLower(strings.TrimSpace(email))).Scan(&userID).Error
|
||||
if userID != 0 {
|
||||
if err := tx.Exec("DELETE FROM generations WHERE user_id=?", userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM users WHERE id=?", userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Exec("DELETE ar FROM active_routes ar JOIN route_pools rp ON rp.id=ar.route_pool_id WHERE rp.slug LIKE 'portal-e2e-%'").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM route_pools WHERE slug LIKE 'portal-e2e-%'").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM provider_models WHERE provider_id IN (SELECT id FROM providers WHERE slug='portal-e2e')").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM providers WHERE slug='portal-e2e'").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec("DELETE FROM prompt_templates WHERE template_key LIKE 'portal-e2e-%'").Error
|
||||
})
|
||||
}
|
||||
|
||||
func ownedGeneration(db *gorm.DB, generationID uint64, email string) (model.Generation, error) {
|
||||
var generation model.Generation
|
||||
err := db.Table("generations g").Select("g.*").Joins("JOIN users u ON u.id=g.user_id").Where("g.id=? AND u.email=?", generationID, email).Take(&generation).Error
|
||||
|
||||
@@ -27,6 +27,7 @@ test.describe("portal responsive workflow", () => {
|
||||
Date.now = () => 1_787_280_000_000;
|
||||
Math.random = () => 0.125;
|
||||
});
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("heading", { name: "登录 Chorus", level: 2 })).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
@@ -44,25 +45,27 @@ test.describe("portal responsive workflow", () => {
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
|
||||
await expect(page.locator("#composer-form")).toBeVisible();
|
||||
await expect(page.locator("#result-section")).toBeVisible();
|
||||
await expect(page.locator(".history-end, .history-empty")).toBeVisible();
|
||||
await expect(page.locator("#result-section")).toHaveCount(0);
|
||||
await expect(page.locator(".history-feedback:not(.hidden), .history-empty")).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectMinimumTargetSize(page, ".button, .icon-button, .mode-button, .history-item");
|
||||
await expectMinimumTargetSize(page, ".button, .icon-button, .mode-button, .history-item, .role-button, .order-button");
|
||||
const width = testInfo.project.use.viewport.width;
|
||||
const result = await page.locator("#result-section").boundingBox();
|
||||
const composer = await page.locator("#composer-form").boundingBox();
|
||||
const promptFlow = await page.locator(".prompt-panel").boundingBox();
|
||||
const history = await page.locator(".history-panel").boundingBox();
|
||||
if (width < 768) {
|
||||
expect(result.y).toBeLessThan(composer.y);
|
||||
expect(composer.y).toBeLessThan(history.y);
|
||||
expect(composer.y).toBeLessThan(promptFlow.y);
|
||||
expect(promptFlow.y).toBeLessThan(history.y);
|
||||
} else {
|
||||
expect(history.x).toBeLessThan(result.x);
|
||||
expect(history.x).toBeLessThan(composer.x);
|
||||
}
|
||||
if (width >= 1100) expect(result.x).toBeLessThan(composer.x);
|
||||
if (width >= 1024) expect(composer.x).toBeLessThan(promptFlow.x);
|
||||
const reducedTransition = await page.locator(".mode-button").first().evaluate(element => Number.parseFloat(getComputedStyle(element).transitionDuration));
|
||||
expect(reducedTransition).toBeLessThanOrEqual(0.01);
|
||||
|
||||
await page.locator('[data-mode="text"]').click();
|
||||
await page.locator('[data-mode="generate"]').click();
|
||||
await expect(page.locator("#image-fields")).toBeHidden();
|
||||
await page.locator('[data-mode="image"]').click();
|
||||
await page.locator('[data-mode="edit"]').click();
|
||||
await expect(page.locator("#image-fields")).toBeVisible();
|
||||
await page.locator("#image-upload").setInputFiles({
|
||||
name: "not-an-image.txt",
|
||||
@@ -71,7 +74,7 @@ test.describe("portal responsive workflow", () => {
|
||||
});
|
||||
await expect(page.locator("#upload-error")).toBeVisible();
|
||||
await expect(page.locator("#image-upload")).toBeFocused();
|
||||
await page.locator('[data-mode="text"]').click();
|
||||
await page.locator('[data-mode="generate"]').click();
|
||||
await page.locator("#prompt").fill("responsive workflow");
|
||||
await page.locator("#generate-button").click();
|
||||
await expect(page).toHaveURL(/\/generations\/\d+$/);
|
||||
@@ -80,6 +83,28 @@ test.describe("portal responsive workflow", () => {
|
||||
expect(externalRequests).toEqual([]);
|
||||
expect(browserErrors).toEqual([]);
|
||||
|
||||
const historyCount = await page.locator("#history-list [data-history-search]").count();
|
||||
await page.locator("#history-controls").evaluate(element => {
|
||||
element.dataset.hasMore = "true";
|
||||
element.dataset.cursor = "test-cursor";
|
||||
});
|
||||
await page.locator("#load-more-history").evaluate(element => element.classList.remove("hidden"));
|
||||
await page.route("**/api/generations?*", route => route.abort("failed"));
|
||||
await page.locator("#load-more-history").click();
|
||||
await expect(page.locator("#history-error")).toBeVisible();
|
||||
await expect(page.locator("#history-list [data-history-search]")).toHaveCount(historyCount);
|
||||
await page.unroute("**/api/generations?*");
|
||||
await page.route("**/api/generations?*", route => route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [{ id: 999999, kind: "image", status: "failed", user_prompt: "历史恢复记录", created_at: new Date().toISOString() }], next_cursor: "", has_more: false })
|
||||
}));
|
||||
await page.locator("#load-more-history").click();
|
||||
await expect(page.getByText("历史恢复记录")).toBeVisible();
|
||||
await expect(page.locator("#history-end")).toBeVisible();
|
||||
await expect(page.locator("#history-list [data-history-search]")).toHaveCount(historyCount + 1);
|
||||
await page.unroute("**/api/generations?*");
|
||||
|
||||
await page.route("**/ui/generations/*/result", route => route.abort("failed"));
|
||||
await expect(page.getByText("状态更新失败。当前内容仍然保留,正在等待网络恢复。")).toBeVisible({ timeout: 6_000 });
|
||||
await page.unroute("**/ui/generations/*/result");
|
||||
@@ -90,48 +115,68 @@ test.describe("portal responsive workflow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("browser observes text, image, and failed terminal states", async ({ page }, testInfo) => {
|
||||
test("browser submits image generation and ordered image editing", async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "desktop-small", "terminal state flow runs once");
|
||||
test.skip(!email || !password || !process.env.CHORUS_E2E_DSN || !process.env.CHORUS_E2E_STORAGE_ROOT, "E2E fixture environment is required");
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await login(page);
|
||||
await expect(page.locator("#composer-form")).toBeVisible();
|
||||
await page.locator('[data-mode="text"]').click();
|
||||
await expect(page.locator('[data-mode="text"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await page.locator("#prompt").fill("browser text success");
|
||||
await page.locator('[data-mode="generate"]').click();
|
||||
await expect(page.locator('[data-mode="generate"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await page.locator("#prompt").fill("browser image generation success");
|
||||
await page.locator("#generate-button").click();
|
||||
await expect(page).toHaveURL(/\/generations\/\d+$/);
|
||||
const textID = generationID(page.url());
|
||||
fixture("running", textID);
|
||||
const generatedID = generationID(page.url());
|
||||
fixture("running", generatedID);
|
||||
await page.reload();
|
||||
await expect(page.getByText("正在生成内容")).toBeVisible();
|
||||
fixture("text-success", textID);
|
||||
await expect(page.getByText("MVP-0 browser text result")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.locator("#result-section")).not.toHaveAttribute("hx-get");
|
||||
|
||||
await page.goto("/");
|
||||
await page.locator('[data-mode="image"]').click();
|
||||
await expect(page.locator('[data-mode="image"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await page.locator("#image-upload").setInputFiles({
|
||||
name: "source.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAFElEQVR4nGPkWfSfAQkwMaABFQAAZQ4BAagN7JwAAAAASUVORK5CYII=", "base64")
|
||||
});
|
||||
await page.locator("#prompt").fill("browser image success");
|
||||
await page.locator("#generate-button").click();
|
||||
await expect(page).toHaveURL(/\/generations\/\d+$/);
|
||||
const imageID = generationID(page.url());
|
||||
fixture("running", imageID);
|
||||
await page.reload();
|
||||
await expect(page.getByText("正在生成内容")).toBeVisible();
|
||||
fixture("image-success", imageID);
|
||||
fixture("image-success", generatedID);
|
||||
await expect(page.locator(".result-gallery img")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.locator("#result-section")).not.toHaveAttribute("hx-get");
|
||||
|
||||
await page.goto("/");
|
||||
await page.locator('[data-mode="text"]').click();
|
||||
await expect(page.locator('[data-mode="text"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await page.locator('[data-mode="edit"]').click();
|
||||
await expect(page.locator('[data-mode="edit"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await page.locator("#image-upload").setInputFiles({
|
||||
name: "primary.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAFElEQVR4nGPkWfSfAQkwMaABFQAAZQ4BAagN7JwAAAAASUVORK5CYII=", "base64")
|
||||
});
|
||||
await page.locator("#image-upload").setInputFiles({
|
||||
name: "reference.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAFElEQVR4nGPkWfSfAQkwMaABFQAAZQ4BAagN7JwAAAAASUVORK5CYII=", "base64")
|
||||
});
|
||||
await expect(page.locator(".image-editor")).toHaveCount(2);
|
||||
await page.locator('.image-editor').nth(1).getByRole("button", { name: "主体图" }).click();
|
||||
await page.locator('.image-editor').nth(1).locator(".image-note").fill("保留参考图的材质细节");
|
||||
await page.getByRole("button", { name: "上移第 2 张图" }).click();
|
||||
await expect(page.locator(".image-title").first()).toHaveText("reference.png");
|
||||
await page.locator("#role-rule").fill("主体图决定轮廓,参考图只提供材质。");
|
||||
await page.locator("#prompt").fill("browser ordered image edit success");
|
||||
await page.screenshot({ path: testInfo.outputPath("image-editor.png") });
|
||||
await page.locator("#generate-button").click();
|
||||
await expect(page).toHaveURL(/\/generations\/\d+$/);
|
||||
const editID = generationID(page.url());
|
||||
const detailResponse = await page.request.get(`/api/generations/${editID}`);
|
||||
expect(detailResponse.ok()).toBe(true);
|
||||
const detail = await detailResponse.json();
|
||||
expect(detail.role_rule).toBe("主体图决定轮廓,参考图只提供材质。");
|
||||
expect(detail.inputs.map(input => [input.position, input.role, input.note, input.name])).toEqual([
|
||||
[0, "primary", "保留参考图的材质细节", "reference.png"],
|
||||
[1, "reference", null, "primary.png"]
|
||||
]);
|
||||
fixture("running", editID);
|
||||
await page.reload();
|
||||
await expect(page.getByText("正在生成内容")).toBeVisible();
|
||||
fixture("image-success", editID);
|
||||
await expect(page.locator(".result-gallery img")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.locator("#result-section")).not.toHaveAttribute("hx-get");
|
||||
|
||||
await page.goto("/");
|
||||
await page.locator('[data-mode="generate"]').click();
|
||||
await expect(page.locator('[data-mode="generate"]')).toHaveAttribute("aria-pressed", "true");
|
||||
await page.locator("#prompt").fill("browser classified failure");
|
||||
await page.locator("#generate-button").click();
|
||||
await expect(page).toHaveURL(/\/generations\/\d+$/);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
AlertCircle, Check, Clock3, Copy, Download, Eye, EyeOff,
|
||||
AlertCircle, ArrowDown, ArrowUp, Check, Clock3, Copy, Download, Eye, EyeOff,
|
||||
FileText, Image, LogOut, Plus, RefreshCw, Search, ShieldCheck, X,
|
||||
createIcons
|
||||
} from "lucide";
|
||||
|
||||
const icons = {
|
||||
AlertCircle, Check, Clock3, Copy, Download, Eye, EyeOff,
|
||||
AlertCircle, ArrowDown, ArrowUp, Check, Clock3, Copy, Download, Eye, EyeOff,
|
||||
FileText, Image, LogOut, Plus, RefreshCw, Search, ShieldCheck, X
|
||||
};
|
||||
|
||||
|
||||
+58
-16
@@ -52,7 +52,7 @@ button:disabled { cursor: not-allowed; opacity: .48; }
|
||||
.account-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.user-name { max-width: 130px; overflow: hidden; color: var(--text-muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.button, .icon-button, .mode-button, .history-item { min-height: 44px; border-radius: var(--radius); transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease; }
|
||||
.button, .icon-button, .mode-button, .history-item, .order-button, .role-button { min-height: 44px; border-radius: var(--radius); transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease; }
|
||||
.button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 44px; padding: 10px 16px; font-weight: 650; border: 1px solid transparent; text-decoration: none; }
|
||||
.button-primary { color: #fff; background: var(--primary); border-color: var(--primary); }
|
||||
.button-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||
@@ -75,7 +75,7 @@ button:disabled { cursor: not-allowed; opacity: .48; }
|
||||
.search-field .input { padding-left: 38px; }
|
||||
.input::placeholder, .textarea::placeholder { color: #6f7d83; }
|
||||
.input:hover, .textarea:hover { border-color: #879a9e; }
|
||||
.history-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
|
||||
.history-list { display: grid; gap: 6px; max-height: calc(100dvh - 284px); margin: 0; padding: 0; overflow: auto; list-style: none; }
|
||||
.history-item { display: grid; grid-template-columns: 36px minmax(0,1fr); gap: 10px; width: 100%; padding: 10px; text-align: left; color: var(--text); background: transparent; border: 1px solid transparent; text-decoration: none; }
|
||||
.history-item:hover { background: var(--surface); border-color: var(--border); }
|
||||
.history-item[aria-current="page"] { background: var(--primary-soft); border-color: #9bc7c3; }
|
||||
@@ -87,7 +87,10 @@ button:disabled { cursor: not-allowed; opacity: .48; }
|
||||
.status-dot.pending { background: var(--warning); }
|
||||
.status-dot.running { background: var(--info); }
|
||||
.status-dot.failed { background: var(--danger); }
|
||||
.history-end, .history-empty { margin: 16px 0 0; color: var(--text-soft); font-size: 13px; text-align: center; }
|
||||
.history-end, .history-empty, .history-feedback { margin: 14px 0 0; color: var(--text-soft); font-size: 13px; text-align: center; }
|
||||
.history-controls { display: grid; }
|
||||
.history-controls .load-more { margin-top: 10px; font-size: 13px; }
|
||||
.history-sentinel { width: 100%; height: 1px; }
|
||||
|
||||
.main { grid-area: main; min-width: 0; padding: 24px clamp(16px,3vw,40px) 48px; }
|
||||
.main-inner { width: 100%; max-width: 1120px; margin: 0 auto; }
|
||||
@@ -98,6 +101,7 @@ h1 { margin: 0; font-size: clamp(24px,3vw,32px); line-height: 1.2; letter-spacin
|
||||
h2 { margin: 0; font-size: 18px; line-height: 1.35; letter-spacing: 0; }
|
||||
h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; }
|
||||
.content-grid { display: grid; grid-template-columns: minmax(0,1.45fr) minmax(320px,.75fr); align-items: start; gap: 20px; }
|
||||
.current-result { margin-bottom: 20px; }
|
||||
.panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 1px 2px rgb(23 33 38 / 3%); }
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 60px; padding: 12px 16px; border-bottom: 1px solid var(--border); }
|
||||
.panel-heading-group { min-width: 0; }
|
||||
@@ -125,28 +129,61 @@ h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; }
|
||||
.text-result p { max-width: 70ch; margin: 0; white-space: pre-wrap; line-height: 1.75; }
|
||||
.notice { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; padding: 12px 14px; color: var(--warning); background: var(--warning-soft); border: 1px solid #e5c59e; border-radius: 6px; }
|
||||
|
||||
.composer { position: sticky; top: calc(var(--header-height) + 20px); }
|
||||
.composer-body { padding: 16px; }
|
||||
.composer-body { display: grid; gap: 18px; padding: 18px; }
|
||||
.mode-switch { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px; padding: 4px; background: var(--surface-strong); border: 1px solid var(--border); border-radius: 8px; }
|
||||
.mode-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 10px; color: var(--text-muted); background: transparent; border: 1px solid transparent; font-weight: 650; }
|
||||
.mode-button[aria-pressed="true"] { color: var(--primary); background: var(--surface); border-color: var(--border-strong); box-shadow: 0 1px 2px rgb(23 33 38 / 8%); }
|
||||
.form-field { margin-top: 16px; }
|
||||
.form-field { min-width: 0; }
|
||||
.field-help,.field-error { margin: 6px 0 0; font-size: 13px; }
|
||||
.field-help { color: var(--text-soft); }
|
||||
.field-error { color: var(--danger); font-weight: 600; }
|
||||
.upload-zone { position: relative; display: grid; min-height: 92px; padding: 14px; place-items: center; text-align: center; color: var(--text-muted); background: var(--surface-subtle); border: 1px dashed #8fa2a5; border-radius: 6px; cursor: pointer; }
|
||||
.upload-zone { position: relative; display: grid; min-height: 108px; padding: 16px; place-items: center; text-align: center; color: var(--primary); background: var(--surface-subtle); border: 1px dashed #8fa2a5; border-radius: 6px; cursor: pointer; }
|
||||
.upload-zone:hover { color: var(--primary); background: var(--primary-soft); border-color: var(--primary); }
|
||||
.upload-zone input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
.upload-zone input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
|
||||
.upload-zone:focus-within { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.upload-zone strong { display: block; color: var(--text); }
|
||||
.upload-list { display: grid; gap: 8px; margin-top: 10px; }
|
||||
.upload-item { display: grid; grid-template-columns: 48px minmax(0,1fr) 44px; align-items: center; gap: 10px; min-height: 58px; padding: 6px; background: var(--surface-subtle); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.upload-thumb { width: 48px; height: 48px; object-fit: cover; background: #dfe7e6; border-radius: 4px; }
|
||||
.upload-name { display: block; overflow: hidden; font-size: 13px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.upload-role { display: block; color: var(--text-soft); font-size: 12px; }
|
||||
.upload-zone span { display: flex; align-items: center; flex-direction: column; gap: 5px; }
|
||||
.upload-zone strong { color: var(--text); font-size: 14px; }
|
||||
.upload-zone small { color: var(--text-soft); }
|
||||
.role-rule-box { padding: 12px; background: var(--surface-subtle); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.role-rule-box summary { cursor: pointer; font-weight: 650; }
|
||||
.role-rule-box summary + div { display: grid; gap: 10px; margin-top: 12px; }
|
||||
.section-divider { height: 1px; margin: 2px 0; background: var(--border); }
|
||||
.subheading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
|
||||
.subheading h3 { font-size: 15px; }
|
||||
.subheading p, .thumb-note { margin: 0; color: var(--text-soft); font-size: 12px; }
|
||||
.image-list { display: grid; gap: 12px; margin-top: 12px; }
|
||||
.image-editor { display: grid; grid-template-columns: 96px minmax(0,1fr) 44px; align-items: start; gap: 12px; padding: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.image-editor.dragging { opacity: .62; }
|
||||
.image-editor.drag-over { border-color: var(--primary); box-shadow: 0 0 0 3px rgb(0 107 102 / 10%); }
|
||||
.image-thumb { position: relative; aspect-ratio: 1; overflow: hidden; background: #dfe7e6; border-radius: 5px; }
|
||||
.image-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.image-index { position: absolute; top: 6px; left: 6px; display: grid; width: 24px; height: 24px; place-items: center; color: #fff; background: rgb(23 33 38 / 86%); border-radius: 50%; font-size: 12px; font-weight: 700; }
|
||||
.image-editor-main { display: grid; min-width: 0; gap: 8px; }
|
||||
.image-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.image-title { min-width: 0; overflow: hidden; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.drag-hint { color: var(--text-soft); font-size: 12px; }
|
||||
.role-group { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 6px; }
|
||||
.role-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 7px; color: var(--text-muted); background: var(--surface); border: 1px solid var(--border-strong); font-size: 13px; font-weight: 650; }
|
||||
.role-button[aria-pressed="true"] { color: var(--primary); background: var(--primary-soft); border-color: var(--primary); }
|
||||
.order-controls { display: grid; gap: 6px; }
|
||||
.order-button { display: grid; width: 44px; height: 44px; padding: 0; place-items: center; color: var(--text-muted); background: var(--surface); border: 1px solid var(--border); }
|
||||
.order-button:hover { color: var(--primary); background: var(--primary-soft); border-color: #9bc7c3; }
|
||||
.remove-image { color: var(--danger); }
|
||||
.composer-footer { display: grid; gap: 10px; margin-top: 18px; }
|
||||
.privacy-note { display: flex; align-items: flex-start; gap: 8px; margin: 0; color: var(--text-soft); font-size: 12px; }
|
||||
.privacy-note .icon { width: 16px; height: 16px; flex-basis: 16px; margin-top: 1px; }
|
||||
.prompt-panel { position: sticky; top: calc(var(--header-height) + 20px); }
|
||||
.prompt-flow { display: grid; gap: 0; padding: 16px 16px 16px 30px; }
|
||||
.flow-step { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 10px; padding: 0 0 16px; border-left: 1px solid var(--border); }
|
||||
.flow-step:last-child { padding-bottom: 0; border-left-color: transparent; }
|
||||
.flow-number { z-index: 1; display: grid; width: 28px; height: 28px; margin-left: -14px; place-items: center; color: var(--primary); background: var(--primary-soft); border: 1px solid #9bc7c3; border-radius: 50%; font-size: 13px; font-weight: 750; }
|
||||
.flow-copy { padding-top: 2px; }
|
||||
.flow-copy strong { display: block; font-size: 14px; }
|
||||
.flow-copy p { margin: 3px 0 0; color: var(--text-muted); font-size: 13px; }
|
||||
.read-only { display: block; margin-top: 8px; padding: 8px 10px; color: #41565a; background: var(--surface-subtle); border: 1px solid var(--border); border-radius: 4px; font-family: ui-monospace,SFMono-Regular,Consolas,monospace; font-size: 12px; line-height: 1.55; }
|
||||
.prompt-preview { margin: 0 16px 16px; padding: 12px; background: var(--surface-subtle); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.prompt-preview summary { cursor: pointer; font-size: 14px; font-weight: 650; }
|
||||
.prompt-preview p { margin: 10px 0 0; color: #41565a; font-size: 13px; line-height: 1.65; }
|
||||
|
||||
.modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; padding: 20px; place-items: center; background: rgb(23 33 38 / 58%); }
|
||||
.modal { width: min(100%,440px); padding: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); }
|
||||
@@ -172,9 +209,9 @@ h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; }
|
||||
.password-wrap .input { padding-right: 52px; }
|
||||
.password-wrap .icon-button { position: absolute; top: 0; right: 0; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@media (max-width: 1023px) {
|
||||
.content-grid { grid-template-columns: 1fr; }
|
||||
.composer { position: static; }
|
||||
.prompt-panel { position: static; }
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.workspace { grid-template-areas: "history main"; }
|
||||
@@ -189,12 +226,17 @@ h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; }
|
||||
.main { padding: 18px 12px 24px; }
|
||||
.history-panel { border-top: 1px solid var(--border); border-right: 0; padding: 20px 12px 32px; }
|
||||
.history-sticky { position: static; }
|
||||
.history-list { max-height: none; }
|
||||
.result-body { min-height: 390px; padding: 12px; }
|
||||
.result-frame,.result-frame img { min-height: 270px; }
|
||||
.result-actions { align-items: stretch; flex-direction: column; }
|
||||
.action-group { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||
.state-message { min-height: 300px; padding: 20px 12px; }
|
||||
.text-result { min-height: 300px; padding: 20px; }
|
||||
.image-editor { grid-template-columns: 76px minmax(0,1fr); gap: 10px; }
|
||||
.order-controls { grid-column: 1 / -1; grid-template-columns: repeat(3,minmax(0,1fr)); }
|
||||
.order-button { width: 100%; }
|
||||
.drag-hint { display: none; }
|
||||
.toast { right: 16px; bottom: 16px; left: 16px; max-width: none; }
|
||||
.login-view { grid-template-columns: 1fr; }
|
||||
.login-context { min-height: auto; padding: 24px 20px; }
|
||||
|
||||
File diff suppressed because one or more lines are too long
+391
-158
@@ -2,11 +2,16 @@
|
||||
"use strict";
|
||||
|
||||
const csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content || "";
|
||||
const show = (element) => element?.classList.remove("hidden");
|
||||
const hide = (element) => element?.classList.add("hidden");
|
||||
const show = element => element?.classList.remove("hidden");
|
||||
const hide = element => element?.classList.add("hidden");
|
||||
const maxImages = () => Number(document.querySelector('meta[name="max-images"]')?.content || 8);
|
||||
const maxPromptBytes = () => Number(document.querySelector('meta[name="max-prompt-bytes"]')?.content || 8000);
|
||||
let toastTimer;
|
||||
let selectedFiles = [];
|
||||
let previewURLs = [];
|
||||
let selectedImages = [];
|
||||
let dragIndex = null;
|
||||
let historyLoading = false;
|
||||
let historyObserver;
|
||||
let legacyTextRetry = false;
|
||||
|
||||
function toast(message) {
|
||||
const element = document.querySelector("#toast");
|
||||
@@ -24,121 +29,248 @@
|
||||
|
||||
function initializeAlpine() {
|
||||
window.Alpine.data("composer", () => ({
|
||||
mode: "image",
|
||||
textPressed: "false",
|
||||
imagePressed: "true",
|
||||
mode: "edit",
|
||||
generatePressed: "false",
|
||||
editPressed: "true",
|
||||
imageClass: "",
|
||||
setText() {
|
||||
this.mode = "text";
|
||||
this.textPressed = "true";
|
||||
this.imagePressed = "false";
|
||||
setGenerate() {
|
||||
this.mode = "generate";
|
||||
this.generatePressed = "true";
|
||||
this.editPressed = "false";
|
||||
this.imageClass = "hidden";
|
||||
document.querySelector("#generation-kind").value = "text";
|
||||
document.querySelector("#generation-mode").value = "generate";
|
||||
handleModeChanged("generate");
|
||||
},
|
||||
setImage() {
|
||||
this.mode = "image";
|
||||
this.textPressed = "false";
|
||||
this.imagePressed = "true";
|
||||
setEdit() {
|
||||
this.mode = "edit";
|
||||
this.generatePressed = "false";
|
||||
this.editPressed = "true";
|
||||
this.imageClass = "";
|
||||
document.querySelector("#generation-kind").value = "image";
|
||||
document.querySelector("#generation-mode").value = "edit";
|
||||
handleModeChanged("edit");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function renderUploads() {
|
||||
const list = document.querySelector("#upload-list");
|
||||
if (!list) return;
|
||||
previewURLs.forEach(URL.revokeObjectURL);
|
||||
previewURLs = [];
|
||||
list.replaceChildren();
|
||||
selectedFiles.forEach((file, index) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "upload-item";
|
||||
const image = document.createElement("img");
|
||||
image.className = "upload-thumb";
|
||||
image.width = 48;
|
||||
image.height = 48;
|
||||
image.alt = index === 0 ? "主图缩略图" : `第 ${index + 1} 张参考图缩略图`;
|
||||
image.src = URL.createObjectURL(file);
|
||||
previewURLs.push(image.src);
|
||||
const copy = document.createElement("span");
|
||||
const name = document.createElement("span");
|
||||
name.className = "upload-name";
|
||||
name.textContent = file.name;
|
||||
const role = document.createElement("span");
|
||||
role.className = "upload-role";
|
||||
role.textContent = index === 0 ? "主图 · 第 1 张" : `参考图 · 第 ${index + 1} 张`;
|
||||
copy.append(name, role);
|
||||
const remove = document.createElement("button");
|
||||
remove.className = "icon-button remove-upload";
|
||||
remove.type = "button";
|
||||
remove.dataset.index = String(index);
|
||||
remove.setAttribute("aria-label", `移除 ${file.name}`);
|
||||
remove.title = "移除图片";
|
||||
const icon = document.createElement("i");
|
||||
icon.className = "icon";
|
||||
icon.dataset.lucide = "x";
|
||||
remove.append(icon);
|
||||
row.append(image, copy, remove);
|
||||
list.append(row);
|
||||
});
|
||||
renderIcons(list);
|
||||
}
|
||||
|
||||
function validateFiles(files) {
|
||||
const allowed = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
const maxImages = Number(document.querySelector('meta[name="max-images"]')?.content || 8);
|
||||
if (files.length > maxImages) return `一次最多添加 ${maxImages} 张图片。`;
|
||||
const invalid = files.find((file) => !allowed.has(file.type));
|
||||
if (invalid) return `${invalid.name}:格式不支持,请改用 JPG、PNG 或 WebP。`;
|
||||
return "";
|
||||
function handleModeChanged(mode) {
|
||||
legacyTextRetry = false;
|
||||
const label = document.querySelector("#generate-button span");
|
||||
if (label) label.textContent = mode === "generate" ? "开始文生图" : "开始图片编辑";
|
||||
hide(document.querySelector("#upload-error"));
|
||||
hide(document.querySelector("#role-rule-error"));
|
||||
updatePromptPreview();
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
document.querySelector(`[data-mode="${mode}"]`)?.click();
|
||||
}
|
||||
|
||||
function clientID() {
|
||||
const value = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `img-${value}`.slice(0, 64);
|
||||
}
|
||||
|
||||
function iconButton(name, label, action, imageID, disabled = false) {
|
||||
const button = document.createElement("button");
|
||||
button.className = "order-button";
|
||||
if (action === "remove") button.classList.add("remove-image");
|
||||
button.type = "button";
|
||||
button.dataset.action = action;
|
||||
button.dataset.imageId = imageID;
|
||||
button.disabled = disabled;
|
||||
button.setAttribute("aria-label", label);
|
||||
button.title = label;
|
||||
const icon = document.createElement("i");
|
||||
icon.className = "icon";
|
||||
icon.dataset.lucide = name;
|
||||
button.append(icon);
|
||||
return button;
|
||||
}
|
||||
|
||||
function roleButton(image, role, label, iconName) {
|
||||
const button = document.createElement("button");
|
||||
button.className = "role-button";
|
||||
button.type = "button";
|
||||
button.dataset.action = "role";
|
||||
button.dataset.role = role;
|
||||
button.dataset.imageId = image.id;
|
||||
button.setAttribute("aria-pressed", String(image.role === role));
|
||||
const icon = document.createElement("i");
|
||||
icon.className = "icon";
|
||||
icon.dataset.lucide = iconName;
|
||||
button.append(icon, document.createTextNode(label));
|
||||
return button;
|
||||
}
|
||||
|
||||
function releaseImage(image) {
|
||||
if (image.previewURL) URL.revokeObjectURL(image.previewURL);
|
||||
}
|
||||
|
||||
function renderImages() {
|
||||
const list = document.querySelector("#image-list");
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
selectedImages.forEach((image, index) => {
|
||||
const card = document.createElement("article");
|
||||
card.className = "image-editor";
|
||||
card.draggable = true;
|
||||
card.dataset.imageId = image.id;
|
||||
card.setAttribute("aria-label", `第 ${index + 1} 张输入图片:${image.file.name}`);
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "image-thumb";
|
||||
const picture = document.createElement("img");
|
||||
picture.src = image.previewURL;
|
||||
picture.alt = `第 ${index + 1} 张输入图片预览`;
|
||||
picture.width = 96;
|
||||
picture.height = 96;
|
||||
const position = document.createElement("span");
|
||||
position.className = "image-index";
|
||||
position.textContent = String(index + 1);
|
||||
preview.append(picture, position);
|
||||
|
||||
const main = document.createElement("div");
|
||||
main.className = "image-editor-main";
|
||||
const titleRow = document.createElement("div");
|
||||
titleRow.className = "image-title-row";
|
||||
const title = document.createElement("span");
|
||||
title.className = "image-title";
|
||||
title.title = image.file.name;
|
||||
title.textContent = image.file.name;
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "drag-hint";
|
||||
hint.textContent = "可拖动";
|
||||
titleRow.append(title, hint);
|
||||
const roles = document.createElement("div");
|
||||
roles.className = "role-group";
|
||||
roles.setAttribute("role", "group");
|
||||
roles.setAttribute("aria-label", `${image.file.name} 的角色`);
|
||||
roles.append(roleButton(image, "primary", "主体图", "image"), roleButton(image, "reference", "参考图", "file-text"));
|
||||
const noteLabel = document.createElement("label");
|
||||
noteLabel.className = "sr-only";
|
||||
noteLabel.htmlFor = `note-${image.id}`;
|
||||
noteLabel.textContent = `${image.file.name} 的备注`;
|
||||
const note = document.createElement("input");
|
||||
note.className = "input image-note";
|
||||
note.id = `note-${image.id}`;
|
||||
note.dataset.imageId = image.id;
|
||||
note.maxLength = 500;
|
||||
note.value = image.note;
|
||||
note.placeholder = "填写图片备注,最多 500 字符";
|
||||
main.append(titleRow, roles, noteLabel, note);
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "order-controls";
|
||||
controls.append(
|
||||
iconButton("arrow-up", `上移第 ${index + 1} 张图`, "up", image.id, index === 0),
|
||||
iconButton("arrow-down", `下移第 ${index + 1} 张图`, "down", image.id, index === selectedImages.length - 1),
|
||||
iconButton("x", `移除第 ${index + 1} 张图`, "remove", image.id)
|
||||
);
|
||||
card.append(preview, main, controls);
|
||||
list.append(card);
|
||||
});
|
||||
const count = document.querySelector("#image-count");
|
||||
if (count) count.textContent = `${selectedImages.length} / ${maxImages()}`;
|
||||
renderIcons(list);
|
||||
updatePromptPreview();
|
||||
}
|
||||
|
||||
function validateFiles(files) {
|
||||
const allowed = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
if (files.length > maxImages()) return `一次最多添加 ${maxImages()} 张图片。`;
|
||||
const invalid = files.find(file => !allowed.has(file.type));
|
||||
return invalid ? `${invalid.name}:格式不支持,请改用 JPG、PNG 或 WebP。` : "";
|
||||
}
|
||||
|
||||
function ensurePrimary() {
|
||||
if (selectedImages.length && !selectedImages.some(image => image.role === "primary")) selectedImages[0].role = "primary";
|
||||
}
|
||||
|
||||
function updatePromptPreview() {
|
||||
const prompt = document.querySelector("#prompt")?.value.trim() || "";
|
||||
const rule = document.querySelector("#role-rule")?.value.trim() || "";
|
||||
const mode = document.querySelector("#generation-mode")?.value || "edit";
|
||||
const ruleSummary = document.querySelector("#rule-summary");
|
||||
const imageSummary = document.querySelector("#image-summary");
|
||||
const preview = document.querySelector("#prompt-preview-text");
|
||||
if (!ruleSummary || !imageSummary || !preview) return;
|
||||
if (mode === "generate") {
|
||||
ruleSummary.textContent = "文生图不使用图片角色规则。";
|
||||
imageSummary.textContent = "文生图不包含输入图片。";
|
||||
preview.textContent = `管理端默认模板 → ${prompt || "(尚未填写生成要求)"}。`;
|
||||
return;
|
||||
}
|
||||
ruleSummary.textContent = rule ? "非空,将覆盖默认图片角色规则。" : "留空,当前只使用管理端默认规则。";
|
||||
const primaryCount = selectedImages.filter(image => image.role === "primary").length;
|
||||
imageSummary.textContent = selectedImages.length ? `按当前顺序附加:${primaryCount} 张主体图,${selectedImages.length - primaryCount} 张参考图。` : "尚未添加输入图片。";
|
||||
const images = selectedImages.map((image, index) => `第 ${index + 1} 张为${image.role === "primary" ? "主体图" : "参考图"}${image.note ? `,备注:${image.note}` : ""}`).join(";");
|
||||
preview.textContent = `管理端默认规则 → ${rule || "(未填写用户规则)"} → ${images || "尚未添加输入图片"}。`;
|
||||
}
|
||||
|
||||
function moveImage(id, direction) {
|
||||
const index = selectedImages.findIndex(image => image.id === id);
|
||||
const target = direction === "up" ? index - 1 : index + 1;
|
||||
if (index < 0 || target < 0 || target >= selectedImages.length) return;
|
||||
[selectedImages[index], selectedImages[target]] = [selectedImages[target], selectedImages[index]];
|
||||
renderImages();
|
||||
document.querySelector(`[data-image-id="${CSS.escape(id)}"] [data-action="${direction}"]`)?.focus();
|
||||
toast(direction === "up" ? "图片已上移" : "图片已下移");
|
||||
}
|
||||
|
||||
function setImageRole(id, role) {
|
||||
const image = selectedImages.find(item => item.id === id);
|
||||
if (!image) return;
|
||||
if (role === "primary") {
|
||||
selectedImages.forEach(item => { item.role = item.id === id ? "primary" : "reference"; });
|
||||
} else if (image.role === "primary") {
|
||||
const replacement = selectedImages.find(item => item.id !== id);
|
||||
if (!replacement) { toast("图片编辑必须保留一张主体图"); return; }
|
||||
replacement.role = "primary";
|
||||
image.role = "reference";
|
||||
}
|
||||
renderImages();
|
||||
document.querySelector(`[data-image-id="${CSS.escape(id)}"] [data-role="${role}"]`)?.focus();
|
||||
toast("图片角色已更新");
|
||||
}
|
||||
|
||||
async function submitGeneration(event) {
|
||||
event.preventDefault();
|
||||
const prompt = document.querySelector("#prompt");
|
||||
const promptError = document.querySelector("#prompt-error");
|
||||
const uploadError = document.querySelector("#upload-error");
|
||||
const ruleError = document.querySelector("#role-rule-error");
|
||||
const button = document.querySelector("#generate-button");
|
||||
const mode = document.querySelector("#generation-kind").value;
|
||||
const mode = document.querySelector("#generation-mode").value;
|
||||
const promptBytes = new TextEncoder().encode(prompt.value).length;
|
||||
const maxPromptBytes = Number(document.querySelector('meta[name="max-prompt-bytes"]')?.content || 8000);
|
||||
hide(promptError);
|
||||
hide(uploadError);
|
||||
if (!prompt.value.trim() || promptBytes > maxPromptBytes) {
|
||||
promptError.textContent = promptBytes > maxPromptBytes ? `生成要求不能超过 ${maxPromptBytes} 字节。` : "请输入生成要求。";
|
||||
show(promptError);
|
||||
prompt.focus();
|
||||
return;
|
||||
hide(promptError); hide(uploadError); hide(ruleError);
|
||||
if (!prompt.value.trim() || promptBytes > maxPromptBytes()) {
|
||||
promptError.textContent = promptBytes > maxPromptBytes() ? `生成要求不能超过 ${maxPromptBytes()} 字节。` : "请输入生成要求。";
|
||||
show(promptError); prompt.focus(); return;
|
||||
}
|
||||
if (mode === "image") {
|
||||
const fileError = selectedFiles.length === 0 ? "请至少添加 1 张图片。" : validateFiles(selectedFiles);
|
||||
if (fileError) {
|
||||
uploadError.textContent = fileError;
|
||||
show(uploadError);
|
||||
document.querySelector("#image-upload").focus();
|
||||
return;
|
||||
}
|
||||
const roleRule = document.querySelector("#role-rule").value;
|
||||
if (mode === "edit") {
|
||||
const fileError = selectedImages.length === 0 ? "图片编辑至少需要 1 张图片。" : validateFiles(selectedImages.map(image => image.file));
|
||||
if (fileError) { uploadError.textContent = fileError; show(uploadError); document.querySelector("#image-upload").focus(); return; }
|
||||
if (selectedImages.filter(image => image.role === "primary").length !== 1) { uploadError.textContent = "图片编辑必须且只能设置 1 张主体图。"; show(uploadError); document.querySelector("#image-upload").focus(); return; }
|
||||
if (new TextEncoder().encode(roleRule).length > maxPromptBytes()) { ruleError.textContent = `图片角色规则不能超过 ${maxPromptBytes()} 字节。`; show(ruleError); document.querySelector("#role-rule").focus(); return; }
|
||||
}
|
||||
button.disabled = true;
|
||||
button.querySelector("span").textContent = "提交中";
|
||||
button.querySelector("span").textContent = "正在提交";
|
||||
const idempotencyKey = crypto.randomUUID?.() || `web-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
let body;
|
||||
let endpoint = "/api/generations/image";
|
||||
let headers = { "X-CSRF-Token": csrfToken() };
|
||||
let endpoint = "/api/generations/text";
|
||||
if (mode === "text") {
|
||||
let body;
|
||||
if (legacyTextRetry) {
|
||||
endpoint = "/api/generations/text";
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify({ idempotency_key: idempotencyKey, prompt: prompt.value });
|
||||
} else {
|
||||
endpoint = "/api/generations/image";
|
||||
body = new FormData();
|
||||
body.set("idempotency_key", idempotencyKey);
|
||||
body.set("prompt", prompt.value);
|
||||
selectedFiles.forEach((file) => body.append("images", file, file.name));
|
||||
const editing = mode === "edit";
|
||||
body.set("metadata", JSON.stringify({ capability: editing ? "image_edit" : "image_generate", role_rule: editing ? roleRule : "", images: editing ? selectedImages.map((image, position) => ({ client_id: image.id, position, role: image.role, note: image.note })) : [] }));
|
||||
if (editing) selectedImages.forEach(image => body.append(`files[${image.id}]`, image.file, image.file.name));
|
||||
}
|
||||
try {
|
||||
const response = await fetch(endpoint, { method: "POST", headers, body });
|
||||
@@ -146,14 +278,14 @@
|
||||
if (response.status === 401) { showAuthExpired(); return; }
|
||||
if (!response.ok) {
|
||||
const error = payload.error || {};
|
||||
if (error.file) {
|
||||
uploadError.textContent = `${error.file.name}:图片未通过校验,请检查格式和大小。`;
|
||||
show(uploadError);
|
||||
document.querySelector("#image-upload").focus();
|
||||
const imageErrorCodes = new Set(["invalid_metadata", "invalid_capability", "invalid_client_id", "invalid_primary_count"]);
|
||||
if (error.file || String(error.code || "").includes("image") || imageErrorCodes.has(error.code)) {
|
||||
uploadError.textContent = error.file ? `${error.file.name}:图片未通过校验,请检查格式和大小。` : (error.message || "图片信息不符合要求,请检查角色和顺序。");
|
||||
show(uploadError); document.querySelector("#image-upload").focus();
|
||||
} else if (error.code === "invalid_role_rule") {
|
||||
ruleError.textContent = "图片角色规则不符合要求。"; show(ruleError); document.querySelector("#role-rule").focus();
|
||||
} else {
|
||||
promptError.textContent = error.message || "提交失败,请检查输入后重试。";
|
||||
show(promptError);
|
||||
prompt.focus();
|
||||
promptError.textContent = error.message || "提交失败,请检查输入后重试。"; show(promptError); prompt.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -162,7 +294,7 @@
|
||||
toast("提交失败,请检查网络后重试");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.querySelector("span").textContent = "开始生成";
|
||||
button.querySelector("span").textContent = mode === "generate" ? "开始文生图" : "开始图片编辑";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,32 +305,33 @@
|
||||
if (!data || !prompt) return;
|
||||
prompt.value = data.dataset.prompt || "";
|
||||
prompt.dispatchEvent(new Event("input"));
|
||||
setMode(data.dataset.kind || "text");
|
||||
selectedFiles = [];
|
||||
if (data.dataset.kind === "image") {
|
||||
selectedImages.forEach(releaseImage);
|
||||
selectedImages = [];
|
||||
const inputs = Array.from(section.querySelectorAll(".retry-input"));
|
||||
if (data.dataset.kind === "text") { setMode("generate"); legacyTextRetry = true; } else { setMode(inputs.length ? "edit" : "generate"); }
|
||||
document.querySelector("#role-rule").value = data.dataset.roleRule || "";
|
||||
if (inputs.length) {
|
||||
button.disabled = true;
|
||||
try {
|
||||
const inputs = Array.from(section.querySelectorAll(".retry-input"));
|
||||
for (const input of inputs) {
|
||||
const response = await fetch(input.dataset.inputUrl);
|
||||
if (!response.ok) throw new Error("input fetch failed");
|
||||
const blob = await response.blob();
|
||||
selectedFiles.push(new File([blob], input.dataset.inputName || "image", { type: input.dataset.inputMime || blob.type }));
|
||||
const file = new File([blob], input.dataset.inputName || "image", { type: input.dataset.inputMime || blob.type });
|
||||
selectedImages.push({ id: clientID(), file, role: input.dataset.inputRole || "reference", note: input.dataset.inputNote || "", previewURL: URL.createObjectURL(file) });
|
||||
}
|
||||
renderUploads();
|
||||
ensurePrimary(); renderImages();
|
||||
} catch (_) {
|
||||
toast("原图读取失败,请重新选择图片");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
selectedImages.forEach(releaseImage); selectedImages = []; renderImages(); toast("原图读取失败,请重新选择图片");
|
||||
} finally { button.disabled = false; }
|
||||
}
|
||||
updatePromptPreview();
|
||||
document.querySelector("#composer-title")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
prompt.focus();
|
||||
}
|
||||
|
||||
function showAuthExpired() {
|
||||
const modal = document.querySelector("#auth-modal");
|
||||
show(modal);
|
||||
show(document.querySelector("#auth-modal"));
|
||||
document.querySelector("#relogin-button")?.focus();
|
||||
}
|
||||
|
||||
@@ -211,6 +344,93 @@
|
||||
element.querySelector(".result-body")?.prepend(notice);
|
||||
}
|
||||
|
||||
function statusText(status) {
|
||||
return { pending: "排队中", running: "生成中", succeeded: "已完成", failed: "失败" }[status] || "未知";
|
||||
}
|
||||
|
||||
function relativeTime(value) {
|
||||
const elapsed = Math.max(0, Date.now() - new Date(value).getTime());
|
||||
if (elapsed < 60000) return "刚刚";
|
||||
if (elapsed < 3600000) return `${Math.floor(elapsed / 60000)} 分钟前`;
|
||||
if (elapsed < 86400000) return `${Math.floor(elapsed / 3600000)} 小时前`;
|
||||
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
|
||||
}
|
||||
|
||||
function historyItem(row) {
|
||||
const item = document.createElement("li");
|
||||
item.dataset.historySearch = `${row.user_prompt || ""} ${row.kind || ""}`;
|
||||
const link = document.createElement("a");
|
||||
link.className = "history-item";
|
||||
link.href = `/generations/${row.id}`;
|
||||
const iconWrap = document.createElement("span");
|
||||
iconWrap.className = "history-icon";
|
||||
const icon = document.createElement("i");
|
||||
icon.className = "icon";
|
||||
icon.dataset.lucide = row.kind === "image" ? "image" : "file-text";
|
||||
iconWrap.append(icon);
|
||||
const copy = document.createElement("span");
|
||||
copy.className = "history-copy";
|
||||
const title = document.createElement("span");
|
||||
title.className = "history-title";
|
||||
title.textContent = row.user_prompt || `任务 #${row.id}`;
|
||||
title.title = title.textContent;
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "history-meta";
|
||||
const dot = document.createElement("span");
|
||||
dot.className = `status-dot ${row.status === "succeeded" ? "success" : row.status || ""}`;
|
||||
meta.append(dot, document.createTextNode(`${statusText(row.status)} · ${row.kind === "image" ? "图片" : "文本"} · ${relativeTime(row.created_at)}`));
|
||||
copy.append(title, meta);
|
||||
link.append(iconWrap, copy);
|
||||
item.append(link);
|
||||
return item;
|
||||
}
|
||||
|
||||
function applyHistorySearch() {
|
||||
const query = document.querySelector("#history-search")?.value.trim().toLowerCase() || "";
|
||||
document.querySelectorAll("[data-history-search]").forEach(item => item.classList.toggle("hidden", !item.dataset.historySearch.toLowerCase().includes(query)));
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const controls = document.querySelector("#history-controls");
|
||||
const button = document.querySelector("#load-more-history");
|
||||
const list = document.querySelector("#history-list");
|
||||
if (!controls || !button || !list || historyLoading || controls.dataset.hasMore !== "true") return;
|
||||
historyLoading = true;
|
||||
hide(document.querySelector("#history-error"));
|
||||
button.disabled = true;
|
||||
button.querySelector("span").textContent = "正在加载";
|
||||
try {
|
||||
const response = await fetch(`/api/generations?limit=${encodeURIComponent(controls.dataset.limit || "20")}&cursor=${encodeURIComponent(controls.dataset.cursor || "")}`, { headers: { "Accept": "application/json" } });
|
||||
if (response.status === 401) { showAuthExpired(); return; }
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error?.code || "history_failed");
|
||||
const sentinel = document.querySelector("#history-sentinel");
|
||||
(payload.items || []).forEach(row => list.insertBefore(historyItem(row), sentinel));
|
||||
controls.dataset.cursor = payload.next_cursor || "";
|
||||
controls.dataset.hasMore = String(Boolean(payload.has_more));
|
||||
if (payload.has_more) show(button); else { hide(button); show(document.querySelector("#history-end")); }
|
||||
applyHistorySearch(); renderIcons(list);
|
||||
} catch (_) {
|
||||
show(document.querySelector("#history-error")); show(button);
|
||||
} finally {
|
||||
historyLoading = false;
|
||||
button.disabled = false;
|
||||
button.querySelector("span").textContent = "加载更多历史";
|
||||
}
|
||||
}
|
||||
|
||||
function initializeHistory() {
|
||||
const list = document.querySelector("#history-list");
|
||||
const sentinel = document.querySelector("#history-sentinel");
|
||||
document.querySelector("#history-search")?.addEventListener("input", applyHistorySearch);
|
||||
document.querySelector("#load-more-history")?.addEventListener("click", loadHistory);
|
||||
if (!list || !sentinel || typeof IntersectionObserver === "undefined") return;
|
||||
historyObserver = new IntersectionObserver(entries => {
|
||||
if (entries.some(entry => entry.isIntersecting)) loadHistory();
|
||||
}, { root: list, rootMargin: "0px 0px 80px 0px" });
|
||||
historyObserver.observe(sentinel);
|
||||
}
|
||||
|
||||
function initializeLogin() {
|
||||
const form = document.querySelector("#login-form");
|
||||
const toggle = document.querySelector("#toggle-password");
|
||||
@@ -220,79 +440,91 @@
|
||||
input.type = showing ? "password" : "text";
|
||||
toggle.setAttribute("aria-label", showing ? "显示密码" : "隐藏密码");
|
||||
toggle.setAttribute("title", showing ? "显示密码" : "隐藏密码");
|
||||
toggle.querySelector("svg")?.remove();
|
||||
toggle.querySelector("i")?.remove();
|
||||
const icon = document.createElement("i");
|
||||
icon.className = "icon";
|
||||
icon.dataset.lucide = showing ? "eye" : "eye-off";
|
||||
toggle.append(icon);
|
||||
renderIcons(toggle);
|
||||
toggle.querySelector("svg")?.remove(); toggle.querySelector("i")?.remove();
|
||||
const icon = document.createElement("i"); icon.className = "icon"; icon.dataset.lucide = showing ? "eye" : "eye-off"; toggle.append(icon); renderIcons(toggle);
|
||||
});
|
||||
form?.addEventListener("submit", async (event) => {
|
||||
form?.addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
const email = document.querySelector("#email");
|
||||
const password = document.querySelector("#password");
|
||||
const error = document.querySelector("#login-error");
|
||||
const emailError = document.querySelector("#email-error");
|
||||
const email = document.querySelector("#email"); const password = document.querySelector("#password");
|
||||
const error = document.querySelector("#login-error"); const emailError = document.querySelector("#email-error");
|
||||
hide(error); hide(emailError);
|
||||
if (!email.validity.valid) { show(emailError); email.focus(); return; }
|
||||
if (!password.value) { show(error); password.focus(); return; }
|
||||
const button = document.querySelector("#login-button");
|
||||
button.disabled = true;
|
||||
button.querySelector("span").textContent = "登录中";
|
||||
const button = document.querySelector("#login-button"); button.disabled = true; button.querySelector("span").textContent = "登录中";
|
||||
try {
|
||||
const response = await fetch("/api/session/login", { method:"POST", headers:{"Content-Type":"application/json","X-CSRF-Token":csrfToken()}, body:JSON.stringify({email:email.value,password:password.value}) });
|
||||
const response = await fetch("/api/session/login", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() }, body: JSON.stringify({ email: email.value, password: password.value }) });
|
||||
if (!response.ok) { show(error); password.focus(); return; }
|
||||
const requested = document.body.dataset.returnTo || "/";
|
||||
window.location.assign(requested.startsWith("/") && !requested.startsWith("//") ? requested : "/");
|
||||
} catch (_) { error.textContent="登录请求失败,请检查网络后重试。";show(error); }
|
||||
finally { button.disabled=false;button.querySelector("span").textContent="登录"; }
|
||||
} catch (_) { error.textContent = "登录请求失败,请检查网络后重试。"; show(error); }
|
||||
finally { button.disabled = false; button.querySelector("span").textContent = "登录"; }
|
||||
});
|
||||
}
|
||||
|
||||
function initializeApp() {
|
||||
document.querySelector("#prompt")?.addEventListener("input", (event) => {
|
||||
document.querySelector("#prompt")?.addEventListener("input", event => {
|
||||
document.querySelector("#prompt-count").textContent = String(new TextEncoder().encode(event.target.value).length);
|
||||
hide(document.querySelector("#prompt-error"));
|
||||
hide(document.querySelector("#prompt-error")); updatePromptPreview();
|
||||
});
|
||||
document.querySelector("#image-upload")?.addEventListener("change", (event) => {
|
||||
document.querySelector("#role-rule")?.addEventListener("input", () => { hide(document.querySelector("#role-rule-error")); updatePromptPreview(); });
|
||||
document.querySelector("#image-upload")?.addEventListener("change", event => {
|
||||
const added = Array.from(event.target.files || []);
|
||||
const candidate = selectedFiles.concat(added);
|
||||
const error = validateFiles(candidate);
|
||||
const error = validateFiles(selectedImages.map(image => image.file).concat(added));
|
||||
const output = document.querySelector("#upload-error");
|
||||
if (error) { output.textContent=error;show(output);event.target.focus();return; }
|
||||
hide(output);selectedFiles=candidate;event.target.value="";renderUploads();
|
||||
if (error) { output.textContent = error; show(output); event.target.value = ""; event.target.focus(); return; }
|
||||
hide(output);
|
||||
added.forEach(file => selectedImages.push({ id: clientID(), file, role: selectedImages.length ? "reference" : "primary", note: "", previewURL: URL.createObjectURL(file) }));
|
||||
event.target.value = ""; ensurePrimary(); renderImages();
|
||||
});
|
||||
document.querySelector("#upload-list")?.addEventListener("click", (event) => {
|
||||
const button = event.target.closest(".remove-upload");
|
||||
if (!button) return;
|
||||
selectedFiles.splice(Number(button.dataset.index),1);renderUploads();toast("图片已移除");
|
||||
const imageList = document.querySelector("#image-list");
|
||||
imageList?.addEventListener("input", event => {
|
||||
const input = event.target.closest(".image-note"); if (!input) return;
|
||||
const image = selectedImages.find(item => item.id === input.dataset.imageId); if (image) image.note = input.value;
|
||||
updatePromptPreview();
|
||||
});
|
||||
imageList?.addEventListener("click", event => {
|
||||
const button = event.target.closest("button[data-action]"); if (!button) return;
|
||||
const id = button.dataset.imageId;
|
||||
if (button.dataset.action === "role") { setImageRole(id, button.dataset.role); return; }
|
||||
if (button.dataset.action === "up" || button.dataset.action === "down") { moveImage(id, button.dataset.action); return; }
|
||||
if (button.dataset.action === "remove") {
|
||||
if (selectedImages.length === 1) { toast("图片编辑至少保留一张图片"); return; }
|
||||
const index = selectedImages.findIndex(image => image.id === id); if (index < 0) return;
|
||||
releaseImage(selectedImages[index]); selectedImages.splice(index, 1); ensurePrimary(); renderImages(); toast("图片已移除");
|
||||
}
|
||||
});
|
||||
imageList?.addEventListener("dragstart", event => {
|
||||
const card = event.target.closest(".image-editor"); if (!card) return;
|
||||
dragIndex = selectedImages.findIndex(image => image.id === card.dataset.imageId); card.classList.add("dragging"); event.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
imageList?.addEventListener("dragover", event => { const card = event.target.closest(".image-editor"); if (card) { event.preventDefault(); card.classList.add("drag-over"); } });
|
||||
imageList?.addEventListener("dragleave", event => event.target.closest(".image-editor")?.classList.remove("drag-over"));
|
||||
imageList?.addEventListener("drop", event => {
|
||||
const card = event.target.closest(".image-editor"); if (!card || dragIndex === null) return;
|
||||
event.preventDefault(); const target = selectedImages.findIndex(image => image.id === card.dataset.imageId);
|
||||
if (target >= 0 && target !== dragIndex) { const [moved] = selectedImages.splice(dragIndex, 1); selectedImages.splice(target, 0, moved); renderImages(); toast("图片顺序已调整"); }
|
||||
dragIndex = null;
|
||||
});
|
||||
imageList?.addEventListener("dragend", () => { dragIndex = null; imageList.querySelectorAll(".image-editor").forEach(card => card.classList.remove("dragging", "drag-over")); });
|
||||
document.querySelector("#composer-form")?.addEventListener("submit", submitGeneration);
|
||||
document.querySelector("#history-search")?.addEventListener("input", (event) => {
|
||||
const query=event.target.value.trim().toLowerCase();
|
||||
document.querySelectorAll("[data-history-search]").forEach((item)=>item.classList.toggle("hidden",!item.dataset.historySearch.toLowerCase().includes(query)));
|
||||
initializeHistory();
|
||||
document.querySelector("#logout-button")?.addEventListener("click", async () => { await fetch("/api/session/logout", { method: "POST", headers: { "X-CSRF-Token": csrfToken() } }).catch(() => {}); window.location.assign("/login"); });
|
||||
document.addEventListener("click", async event => {
|
||||
const retry = event.target.closest(".retry-button"); if (retry) { await retryGeneration(retry); return; }
|
||||
const copy = event.target.closest(".copy-button"); if (copy) { const text = copy.closest("#result-section")?.querySelector(".text-result")?.innerText || ""; try { await navigator.clipboard.writeText(text); toast("文本已复制"); } catch (_) { toast("浏览器未允许复制,请手动选择文本"); } }
|
||||
});
|
||||
document.querySelector("#logout-button")?.addEventListener("click", async () => {
|
||||
await fetch("/api/session/logout",{method:"POST",headers:{"X-CSRF-Token":csrfToken()}}).catch(()=>{});
|
||||
window.location.assign("/login");
|
||||
});
|
||||
document.addEventListener("click", async (event) => {
|
||||
const retry=event.target.closest(".retry-button");if(retry){await retryGeneration(retry);return;}
|
||||
const copy=event.target.closest(".copy-button");if(copy){const text=copy.closest("#result-section")?.querySelector(".text-result")?.innerText||"";try{await navigator.clipboard.writeText(text);toast("文本已复制");}catch(_){toast("浏览器未允许复制,请手动选择文本");}}
|
||||
});
|
||||
document.querySelector("#dismiss-auth")?.addEventListener("click",()=>hide(document.querySelector("#auth-modal")));
|
||||
document.querySelector("#relogin-button")?.addEventListener("click",()=>window.location.assign(`/login?return_to=${encodeURIComponent(window.location.pathname)}`));
|
||||
document.addEventListener("keydown",(event)=>{if(event.key==="Escape"){hide(document.querySelector("#auth-modal"));document.querySelector("#main-content")?.focus();}});
|
||||
document.body.addEventListener("htmx:afterSwap",(event)=>renderIcons(event.detail.target));
|
||||
const handlePollingError=(event)=>{
|
||||
const status=event.detail.xhr?.status||0;const element=event.detail.elt;
|
||||
if(status===401){showAuthExpired();return;}
|
||||
showPollingError(element);
|
||||
window.setTimeout(()=>window.htmx?.trigger(element,"retryPoll"),3000);
|
||||
document.querySelector("#dismiss-auth")?.addEventListener("click", () => hide(document.querySelector("#auth-modal")));
|
||||
document.querySelector("#relogin-button")?.addEventListener("click", () => window.location.assign(`/login?return_to=${encodeURIComponent(window.location.pathname)}`));
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape") { hide(document.querySelector("#auth-modal")); document.querySelector("#main-content")?.focus(); } });
|
||||
document.body.addEventListener("htmx:afterSwap", event => renderIcons(event.detail.target));
|
||||
const handlePollingError = event => {
|
||||
const status = event.detail.xhr?.status || 0; const element = event.detail.elt;
|
||||
if (status === 401) { showAuthExpired(); return; }
|
||||
showPollingError(element); window.setTimeout(() => window.htmx?.trigger(element, "retryPoll"), 3000);
|
||||
};
|
||||
document.body.addEventListener("htmx:responseError",handlePollingError);
|
||||
document.body.addEventListener("htmx:sendError",handlePollingError);
|
||||
document.body.addEventListener("htmx:responseError", handlePollingError);
|
||||
document.body.addEventListener("htmx:sendError", handlePollingError);
|
||||
updatePromptPreview();
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", initializeAlpine);
|
||||
@@ -301,4 +533,5 @@
|
||||
if (document.body.dataset.page === "app") initializeApp();
|
||||
renderIcons();
|
||||
});
|
||||
window.addEventListener("beforeunload", () => { selectedImages.forEach(releaseImage); historyObserver?.disconnect(); });
|
||||
})();
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
(()=>{var u={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};var L=([e,a,t])=>{let r=document.createElementNS("http://www.w3.org/2000/svg",e);return Object.keys(a).forEach(o=>{r.setAttribute(o,String(a[o]))}),t?.length&&t.forEach(o=>{let s=L(o);r.appendChild(s)}),r},y=(e,a={})=>{let r={...u,...a};return L(["svg",r,e])};var R=e=>{for(let a in e)if(a.startsWith("aria-")||a==="role"||a==="title")return!0;return!1};var T=(...e)=>e.filter((a,t,r)=>!!a&&a.trim()!==""&&r.indexOf(a)===t).join(" ").trim();var q=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(a,t,r)=>r?r.toUpperCase():t.toLowerCase());var b=e=>{let a=q(e);return a.charAt(0).toUpperCase()+a.slice(1)};var G=e=>Array.from(e.attributes).reduce((a,t)=>(a[t.name]=t.value,a),{}),U=e=>typeof e=="string"?e:!e||!e.class?"":e.class&&typeof e.class=="string"?e.class.split(" "):e.class&&Array.isArray(e.class)?e.class:"",p=(e,{nameAttr:a,icons:t,attrs:r})=>{let o=e.getAttribute(a);if(o==null)return;let s=b(o),f=t[s];if(!f)return console.warn(`${e.outerHTML} icon name was not found in the provided icons object.`);let l=G(e),O=R(l)?{}:{"aria-hidden":"true"},F={...u,"data-lucide":o,...O,...r,...l},v=U(l),H=U(r),D=T("lucide",`lucide-${o}`,...v,...H);D&&Object.assign(F,{class:D});let E=y(f,F);return e.parentNode?.replaceChild(E,e)};var m=[["path",{d:"M20 6 9 17l-5-5"}]];var d=[["circle",{cx:"12",cy:"12",r:"10"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16"}]];var x=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M12 6v6h4"}]];var i=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"}]];var n=[["path",{d:"M12 15V3"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["path",{d:"m7 10 5 5 5-5"}]];var c=[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"}],["path",{d:"m2 2 20 20"}]];var C=[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"}],["circle",{cx:"12",cy:"12",r:"3"}]];var h=[["path",{d:"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"}],["path",{d:"M14 2v5a1 1 0 0 0 1 1h5"}],["path",{d:"M10 9H8"}],["path",{d:"M16 13H8"}],["path",{d:"M16 17H8"}]];var S=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2"}],["circle",{cx:"9",cy:"9",r:"2"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"}]];var g=[["path",{d:"m16 17 5-5-5-5"}],["path",{d:"M21 12H9"}],["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"}]];var w=[["path",{d:"M5 12h14"}],["path",{d:"M12 5v14"}]];var k=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}],["path",{d:"M21 3v5h-5"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}],["path",{d:"M8 16H3v5"}]];var P=[["path",{d:"m21 21-4.34-4.34"}],["circle",{cx:"11",cy:"11",r:"8"}]];var A=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"}],["path",{d:"m9 12 2 2 4-4"}]];var M=[["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]];var B=({icons:e={},nameAttr:a="data-lucide",attrs:t={},root:r=document,inTemplates:o}={})=>{if(!Object.values(e).length)throw new Error(`Please provide an icons object.
|
||||
(()=>{var u={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};var R=([e,a,t])=>{let r=document.createElementNS("http://www.w3.org/2000/svg",e);return Object.keys(a).forEach(o=>{r.setAttribute(o,String(a[o]))}),t?.length&&t.forEach(o=>{let s=R(o);r.appendChild(s)}),r},T=(e,a={})=>{let r={...u,...a};return R(["svg",r,e])};var q=e=>{for(let a in e)if(a.startsWith("aria-")||a==="role"||a==="title")return!0;return!1};var b=(...e)=>e.filter((a,t,r)=>!!a&&a.trim()!==""&&r.indexOf(a)===t).join(" ").trim();var U=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(a,t,r)=>r?r.toUpperCase():t.toLowerCase());var O=e=>{let a=U(e);return a.charAt(0).toUpperCase()+a.slice(1)};var I=e=>Array.from(e.attributes).reduce((a,t)=>(a[t.name]=t.value,a),{}),v=e=>typeof e=="string"?e:!e||!e.class?"":e.class&&typeof e.class=="string"?e.class.split(" "):e.class&&Array.isArray(e.class)?e.class:"",p=(e,{nameAttr:a,icons:t,attrs:r})=>{let o=e.getAttribute(a);if(o==null)return;let s=O(o),f=t[s];if(!f)return console.warn(`${e.outerHTML} icon name was not found in the provided icons object.`);let l=I(e),H=q(l)?{}:{"aria-hidden":"true"},L={...u,"data-lucide":o,...H,...r,...l},E=v(l),G=v(r),y=b("lucide",`lucide-${o}`,...E,...G);y&&Object.assign(L,{class:y});let V=T(f,L);return e.parentNode?.replaceChild(V,e)};var m=[["path",{d:"M12 5v14"}],["path",{d:"m19 12-7 7-7-7"}]];var x=[["path",{d:"m5 12 7-7 7 7"}],["path",{d:"M12 19V5"}]];var i=[["path",{d:"M20 6 9 17l-5-5"}]];var d=[["circle",{cx:"12",cy:"12",r:"10"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16"}]];var n=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M12 6v6h4"}]];var c=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"}]];var C=[["path",{d:"M12 15V3"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["path",{d:"m7 10 5 5 5-5"}]];var h=[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"}],["path",{d:"m2 2 20 20"}]];var S=[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"}],["circle",{cx:"12",cy:"12",r:"3"}]];var g=[["path",{d:"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"}],["path",{d:"M14 2v5a1 1 0 0 0 1 1h5"}],["path",{d:"M10 9H8"}],["path",{d:"M16 13H8"}],["path",{d:"M16 17H8"}]];var w=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2"}],["circle",{cx:"9",cy:"9",r:"2"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"}]];var k=[["path",{d:"m16 17 5-5-5-5"}],["path",{d:"M21 12H9"}],["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"}]];var P=[["path",{d:"M5 12h14"}],["path",{d:"M12 5v14"}]];var A=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}],["path",{d:"M21 3v5h-5"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}],["path",{d:"M8 16H3v5"}]];var M=[["path",{d:"m21 21-4.34-4.34"}],["circle",{cx:"11",cy:"11",r:"8"}]];var B=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"}],["path",{d:"m9 12 2 2 4-4"}]];var F=[["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]];var D=({icons:e={},nameAttr:a="data-lucide",attrs:t={},root:r=document,inTemplates:o}={})=>{if(!Object.values(e).length)throw new Error(`Please provide an icons object.
|
||||
If you want to use all the icons you can import it like:
|
||||
\`import { createIcons, icons } from 'lucide';
|
||||
lucide.createIcons({icons});\``);if(typeof r>"u")throw new Error("`createIcons()` only works in a browser environment.");if(Array.from(r.querySelectorAll(`[${a}]`)).forEach(f=>p(f,{nameAttr:a,icons:e,attrs:t})),o&&Array.from(r.querySelectorAll("template")).forEach(l=>B({icons:e,nameAttr:a,attrs:t,root:l.content,inTemplates:o})),a==="data-lucide"){let f=r.querySelectorAll("[icon-name]");f.length>0&&(console.warn("[Lucide] Some icons were found with the now deprecated icon-name attribute. These will still be replaced for backwards compatibility, but will no longer be supported in v1.0 and you should switch to data-lucide"),Array.from(f).forEach(l=>p(l,{nameAttr:"icon-name",icons:e,attrs:t})))}};var V={AlertCircle:d,Check:m,Clock3:x,Copy:i,Download:n,Eye:C,EyeOff:c,FileText:h,Image:S,LogOut:g,Plus:w,RefreshCw:k,Search:P,ShieldCheck:A,X:M};window.renderChorusIcons=(e=document)=>B({icons:V,attrs:{"aria-hidden":"true"},root:e});document.addEventListener("DOMContentLoaded",()=>window.renderChorusIcons());})();
|
||||
lucide.createIcons({icons});\``);if(typeof r>"u")throw new Error("`createIcons()` only works in a browser environment.");if(Array.from(r.querySelectorAll(`[${a}]`)).forEach(f=>p(f,{nameAttr:a,icons:e,attrs:t})),o&&Array.from(r.querySelectorAll("template")).forEach(l=>D({icons:e,nameAttr:a,attrs:t,root:l.content,inTemplates:o})),a==="data-lucide"){let f=r.querySelectorAll("[icon-name]");f.length>0&&(console.warn("[Lucide] Some icons were found with the now deprecated icon-name attribute. These will still be replaced for backwards compatibility, but will no longer be supported in v1.0 and you should switch to data-lucide"),Array.from(f).forEach(l=>p(l,{nameAttr:"icon-name",icons:e,attrs:t})))}};var W={AlertCircle:d,ArrowDown:m,ArrowUp:x,Check:i,Clock3:n,Copy:c,Download:C,Eye:S,EyeOff:h,FileText:g,Image:w,LogOut:k,Plus:P,RefreshCw:A,Search:M,ShieldCheck:B,X:F};window.renderChorusIcons=(e=document)=>D({icons:W,attrs:{"aria-hidden":"true"},root:e});document.addEventListener("DOMContentLoaded",()=>window.renderChorusIcons());})();
|
||||
/*! Bundled license information:
|
||||
|
||||
lucide/dist/esm/defaultAttributes.mjs:
|
||||
@@ -11,6 +11,8 @@ lucide/dist/esm/shared/src/utils/mergeClasses.mjs:
|
||||
lucide/dist/esm/shared/src/utils/toCamelCase.mjs:
|
||||
lucide/dist/esm/shared/src/utils/toPascalCase.mjs:
|
||||
lucide/dist/esm/replaceElement.mjs:
|
||||
lucide/dist/esm/icons/arrow-down.mjs:
|
||||
lucide/dist/esm/icons/arrow-up.mjs:
|
||||
lucide/dist/esm/icons/check.mjs:
|
||||
lucide/dist/esm/icons/circle-alert.mjs:
|
||||
lucide/dist/esm/icons/clock-3.mjs:
|
||||
|
||||
@@ -29,37 +29,44 @@
|
||||
<label class="field-label history-search-label" for="history-search">搜索历史</label>
|
||||
<div class="search-field"><i class="icon" data-lucide="search"></i><input class="input" id="history-search" type="search" placeholder="搜索历史" autocomplete="off"></div>
|
||||
<h2 class="sr-only" id="history-title">生成历史</h2>
|
||||
{{if .History}}<ul class="history-list" id="history-list">
|
||||
{{if .History}}<ul class="history-list" id="history-list" aria-live="polite">
|
||||
{{range .History}}<li data-history-search="{{.UserPrompt}} {{kindLabel .Kind}}">
|
||||
<a class="history-item" href="/generations/{{.ID}}" {{if $.Current}}{{if eq .ID $.Current.Generation.ID}}aria-current="page"{{end}}{{end}}>
|
||||
<span class="history-icon"><i class="icon" data-lucide="{{kindIcon .Kind}}"></i></span>
|
||||
<span class="history-copy"><span class="history-title">{{shortPrompt .UserPrompt}}</span><span class="history-meta"><span class="status-dot {{statusTone .Status}}"></span>{{kindLabel .Kind}} · {{relativeTime .CreatedAt}}</span></span>
|
||||
<span class="history-copy"><span class="history-title" title="{{.UserPrompt}}">{{shortPrompt .UserPrompt}}</span><span class="history-meta"><span class="status-dot {{statusTone .Status}}"></span>{{statusLabel .Status}} · {{kindLabel .Kind}} · {{relativeTime .CreatedAt}}</span></span>
|
||||
</a>
|
||||
</li>{{end}}
|
||||
</ul><p class="history-end">已显示全部历史,没有更多记录</p>{{else}}<p class="history-empty">还没有生成记录</p>{{end}}
|
||||
</li>{{end}}<li class="history-sentinel" id="history-sentinel" aria-hidden="true"></li>
|
||||
</ul>
|
||||
<div class="history-controls" id="history-controls" data-cursor="{{.HistoryCursor}}" data-has-more="{{.HistoryHasMore}}" data-limit="{{.HistoryLimit}}">
|
||||
<button class="button button-secondary button-block load-more {{if not .HistoryHasMore}}hidden{{end}}" id="load-more-history" type="button"><i class="icon" data-lucide="refresh-cw"></i><span>加载更多历史</span></button>
|
||||
<p class="history-feedback hidden" id="history-error" role="alert">加载更多记录失败,已显示的记录不会丢失。</p>
|
||||
<p class="history-feedback {{if .HistoryHasMore}}hidden{{end}}" id="history-end">已显示全部历史,没有更多记录</p>
|
||||
</div>{{else}}<p class="history-empty">还没有生成记录。提交第一条任务后会显示在这里。</p>{{end}}
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main" id="main-content" tabindex="-1">
|
||||
<div class="main-inner">
|
||||
<div class="page-heading"><div><p class="eyebrow">生成工作台</p><h1>{{if .Current}}{{shortPrompt .Current.Generation.UserPrompt}}{{else}}新建生成{{end}}</h1></div></div>
|
||||
{{if .Current}}<section class="panel result-panel current-result" aria-labelledby="result-title">{{template "result" .}}</section>{{end}}
|
||||
<div class="content-grid">
|
||||
<section class="panel result-panel" aria-labelledby="result-title">{{template "result" .}}</section>
|
||||
<section class="panel composer" aria-labelledby="composer-title" x-data="composer">
|
||||
<div class="panel-header"><div class="panel-heading-group"><h2 id="composer-title">生成内容</h2><p>选择类型并描述需要的结果</p></div></div>
|
||||
<form class="composer-body" id="composer-form" novalidate>
|
||||
<div class="mode-switch" role="group" aria-label="生成类型">
|
||||
<button class="mode-button" type="button" data-mode="text" x-on:click="setText" x-bind:aria-pressed="textPressed"><i class="icon" data-lucide="file-text"></i>文本</button>
|
||||
<button class="mode-button" type="button" data-mode="image" x-on:click="setImage" x-bind:aria-pressed="imagePressed"><i class="icon" data-lucide="image"></i>图片</button>
|
||||
<button class="mode-button" type="button" data-mode="generate" x-on:click="setGenerate" x-bind:aria-pressed="generatePressed"><i class="icon" data-lucide="file-text"></i>文生图</button>
|
||||
<button class="mode-button" type="button" data-mode="edit" x-on:click="setEdit" x-bind:aria-pressed="editPressed"><i class="icon" data-lucide="image"></i>图片编辑</button>
|
||||
</div>
|
||||
<input id="generation-kind" type="hidden" value="image">
|
||||
<div class="form-field"><label class="field-label" for="prompt">描述你想要的结果</label><textarea class="textarea" id="prompt" name="prompt" required></textarea><p class="field-help"><span id="prompt-count">0</span> 字节</p><p class="field-error hidden" id="prompt-error" role="alert">请输入生成要求。</p></div>
|
||||
<input id="generation-mode" type="hidden" value="edit">
|
||||
<div class="form-field"><label class="field-label" for="prompt">描述你想要的结果</label><textarea class="textarea" id="prompt" name="prompt" required placeholder="例如:保留主体轮廓,替换成明亮简洁的室内场景。"></textarea><p class="field-help"><span id="prompt-count">0</span> 字节 · 输入内容仅用于当前任务。</p><p class="field-error hidden" id="prompt-error" role="alert">请输入生成要求。</p></div>
|
||||
<div id="image-fields" x-bind:class="imageClass" x-cloak>
|
||||
<div class="form-field"><span class="field-label">参考图片</span><label class="upload-zone"><input id="image-upload" type="file" accept="image/jpeg,image/png,image/webp" multiple><span><i class="icon" data-lucide="plus"></i><strong>添加图片</strong>JPG、PNG 或 WebP,第一张为主图</span></label><p class="field-error hidden" id="upload-error" role="alert"></p></div>
|
||||
<div class="upload-list" id="upload-list"></div>
|
||||
<details class="role-rule-box" open><summary>图片角色规则 <span class="thumb-note">可选,覆盖管理端默认规则</span></summary><div><label class="field-label" for="role-rule">你的 role_rule</label><textarea class="textarea" id="role-rule" placeholder="例如:第一张作为主体参考,其他图片仅用于补充材质和风格。"></textarea><p class="field-help">留空时使用管理端默认规则;每张图片的角色和备注仍会按顺序附加。</p><p class="field-error hidden" id="role-rule-error" role="alert"></p></div></details>
|
||||
<div class="section-divider"></div>
|
||||
<section aria-labelledby="input-images-title"><div class="subheading"><div><h3 id="input-images-title">输入图片</h3><p>拖动可调整顺序;也可使用上移和下移按钮。</p></div><span class="thumb-note" id="image-count">0 / {{.MaxImages}}</span></div><label class="upload-zone"><input id="image-upload" type="file" accept="image/jpeg,image/png,image/webp" multiple><span><i class="icon" data-lucide="plus"></i><strong>添加图片</strong><small>JPG、PNG 或 WebP;每张图都需要角色。</small></span></label><p class="field-error hidden" id="upload-error" role="alert"></p><div class="image-list" id="image-list" aria-live="polite"></div></section>
|
||||
</div>
|
||||
<div class="composer-footer"><button class="button button-primary button-block" id="generate-button" type="submit"><i class="icon" data-lucide="plus"></i><span>开始生成</span></button><p class="privacy-note"><i class="icon" data-lucide="shield-check"></i>任务和结果仅对当前账户可见。</p></div>
|
||||
</form>
|
||||
</section>
|
||||
<aside class="panel prompt-panel" aria-labelledby="prompt-flow-title"><div class="panel-header"><div class="panel-heading-group"><h2 id="prompt-flow-title">最终 Prompt 构成</h2><p>按固定顺序合成,提交后写入任务快照。</p></div></div><div class="prompt-flow"><div class="flow-step"><span class="flow-number">1</span><div class="flow-copy"><strong>管理端默认规则</strong><p>作为基础模板,用户不能在这里编辑。</p><code class="read-only">默认模板根据当前路由配置生成。</code></div></div><div class="flow-step"><span class="flow-number">2</span><div class="flow-copy"><strong>你的 role_rule</strong><p id="rule-summary">留空,当前只使用管理端默认规则。</p></div></div><div class="flow-step"><span class="flow-number">3</span><div class="flow-copy"><strong>图片角色与备注</strong><p id="image-summary">尚未添加输入图片。</p></div></div></div><details class="prompt-preview"><summary>查看提交内容预览</summary><p id="prompt-preview-text">管理端默认规则 → (未填写用户规则) → 尚未添加输入图片。</p></details></aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
{{else if eq .Current.Generation.Status "failed"}}<div class="state-message"><span class="state-icon danger"><i class="icon" data-lucide="alert-circle"></i></span><h3>这次生成没有完成</h3><p>{{if .Current.Generation.ErrorMessage}}{{value .Current.Generation.ErrorMessage}}{{else}}生成服务暂时不可用,输入已经保留。{{end}}</p><button class="button button-primary retry-button" type="button" data-generation-id="{{.Current.Generation.ID}}"><i class="icon" data-lucide="refresh-cw"></i>再次生成</button></div>
|
||||
{{else if eq .Current.Generation.Kind "text"}}<div><article class="text-result">{{range .Current.Outputs}}{{if .TextContent}}<p>{{value .TextContent}}</p>{{end}}{{end}}</article><div class="result-actions"><span class="result-meta">文本结果</span><div class="action-group"><button class="button button-secondary retry-button" type="button" data-generation-id="{{.Current.Generation.ID}}"><i class="icon" data-lucide="refresh-cw"></i>再次生成</button><button class="button button-primary copy-button" type="button"><i class="icon" data-lucide="copy"></i>复制</button></div></div></div>
|
||||
{{else}}<div class="result-gallery">{{range .Current.Outputs}}{{if .StorageKey}}<div><a class="result-frame" href="/api/generations/{{$.Current.Generation.ID}}/outputs/{{.ID}}" target="_blank" rel="noopener" aria-label="查看生成图片"><img src="/api/generations/{{$.Current.Generation.ID}}/outputs/{{.ID}}" alt="生成图片结果" loading="eager"></a><div class="result-actions"><span class="result-meta">{{if .MIMEType}}{{value .MIMEType}}{{else}}图片结果{{end}}</span><div class="action-group"><button class="button button-secondary retry-button" type="button" data-generation-id="{{$.Current.Generation.ID}}"><i class="icon" data-lucide="refresh-cw"></i>再次生成</button><a class="button button-primary" href="/api/generations/{{$.Current.Generation.ID}}/outputs/{{.ID}}" download><i class="icon" data-lucide="download"></i>下载</a></div></div></div>{{end}}{{end}}</div>{{end}}
|
||||
{{if .Current}}{{range .Current.Inputs}}<span class="retry-input sr-only" data-input-url="/api/generations/{{$.Current.Generation.ID}}/inputs/{{.ID}}" data-input-name="{{.OriginalName}}" data-input-mime="{{.MIMEType}}"></span>{{end}}<span class="retry-data sr-only" data-kind="{{.Current.Generation.Kind}}" data-prompt="{{.Current.Generation.UserPrompt}}"></span>{{end}}
|
||||
{{if .Current}}{{range .Current.Inputs}}<span class="retry-input sr-only" data-input-url="/api/generations/{{$.Current.Generation.ID}}/inputs/{{.ID}}" data-input-name="{{.OriginalName}}" data-input-mime="{{.MIMEType}}" data-input-role="{{.Role}}" data-input-note="{{value .Note}}"></span>{{end}}<span class="retry-data sr-only" data-kind="{{.Current.Generation.Kind}}" data-prompt="{{.Current.Generation.UserPrompt}}" data-role-rule="{{value .Current.Generation.RoleRule}}"></span>{{end}}
|
||||
</div>
|
||||
</div>{{end}}
|
||||
|
||||
@@ -23,6 +23,9 @@ type Page struct {
|
||||
CSRFToken string
|
||||
ReturnTo string
|
||||
History []model.Generation
|
||||
HistoryCursor string
|
||||
HistoryHasMore bool
|
||||
HistoryLimit int
|
||||
Current *service.Detail
|
||||
MaxPromptBytes int
|
||||
MaxImages int
|
||||
|
||||
Reference in New Issue
Block a user