dsh-plugin-image-workbench 0.0.0-stage → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +258 -0
- package/README.md +260 -2
- package/client.js +241 -0
- package/cordis.patch.yml +14 -0
- package/host.js +692 -0
- package/install.mjs +333 -0
- package/package.json +58 -4
- package/scripts/fetch-style-images.mjs +104 -0
- package/scripts/vendor-snapshot.py +154 -0
- package/server/access.py +26 -0
- package/server/app.py +1329 -0
- package/server/catalog.json +1 -0
- package/server/catalog.py +147 -0
- package/server/composer.py +391 -0
- package/server/drafts.py +158 -0
- package/server/history.py +575 -0
- package/server/imaging.py +172 -0
- package/server/pool.py +1335 -0
- package/server/pool_admin.py +921 -0
- package/server/pool_service.py +1073 -0
- package/server/requirements.txt +6 -0
- package/server/smoke_check.py +402 -0
- package/server/static/access.html +202 -0
- package/server/static/index.html +4725 -0
- package/vendor/chatgpt2api/README.md +13 -0
- package/vendor/chatgpt2api/SOURCE.json +11 -0
- package/vendor/chatgpt2api/runtime/uv.exe +0 -0
- package/vendor/chatgpt2api/source/.env.example +82 -0
- package/vendor/chatgpt2api/source/.python-version +1 -0
- package/vendor/chatgpt2api/source/LICENSE +21 -0
- package/vendor/chatgpt2api/source/README.md +391 -0
- package/vendor/chatgpt2api/source/VERSION +1 -0
- package/vendor/chatgpt2api/source/api/__init__.py +2 -0
- package/vendor/chatgpt2api/source/api/accounts.py +537 -0
- package/vendor/chatgpt2api/source/api/ai.py +217 -0
- package/vendor/chatgpt2api/source/api/app.py +62 -0
- package/vendor/chatgpt2api/source/api/errors.py +46 -0
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
- package/vendor/chatgpt2api/source/api/support.py +134 -0
- package/vendor/chatgpt2api/source/api/system.py +365 -0
- package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
- package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
- package/vendor/chatgpt2api/source/config.default.json +108 -0
- package/vendor/chatgpt2api/source/main.py +9 -0
- package/vendor/chatgpt2api/source/pyproject.toml +27 -0
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
- package/vendor/chatgpt2api/source/services/__init__.py +0 -0
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
- package/vendor/chatgpt2api/source/services/config.py +667 -0
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
- package/vendor/chatgpt2api/source/services/image_service.py +376 -0
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
- package/vendor/chatgpt2api/source/services/log_service.py +337 -0
- package/vendor/chatgpt2api/source/services/model_service.py +182 -0
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
- package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
- package/vendor/chatgpt2api/source/utils/helper.py +469 -0
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
- package/vendor/chatgpt2api/source/utils/log.py +110 -0
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
- package/vendor/chatgpt2api/source/utils/pow.py +207 -0
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
- package/vendor/chatgpt2api/source/uv.lock +904 -0
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
- package/vendor/chatgpt2api/source/web/components.json +24 -0
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
- package/vendor/chatgpt2api/source/web/package.json +58 -0
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
- package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
- package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
|
@@ -0,0 +1,4725 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Bluxe手绘工坊 · 生图工作台</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{
|
|
9
|
+
--bg:#f5f3fb; --panel:#ffffff; --ink:#1f1b2e; --ink-2:#5b5573; --ink-3:#8b86a3;
|
|
10
|
+
--line:#e8e4f5; --primary:#6a5ae0; --primary-2:#8b7cf6; --pink:#ff6b9a;
|
|
11
|
+
--ok:#12b886; --warn:#f59f00; --err:#e03131; --chip:#f1eefe;
|
|
12
|
+
}
|
|
13
|
+
*{box-sizing:border-box}
|
|
14
|
+
body{margin:0;background:var(--bg);color:var(--ink);
|
|
15
|
+
font:13px/1.6 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;}
|
|
16
|
+
header{display:flex;align-items:center;gap:12px;padding:12px 18px;background:#fff;
|
|
17
|
+
border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10}
|
|
18
|
+
header h1{font-size:15px;margin:0 0 2px;letter-spacing:.5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
19
|
+
header .sub{font-size:11px;color:var(--ink-3)}
|
|
20
|
+
header .gh-link{display:inline-block;padding:2px 9px;border-radius:20px;font-size:11px;font-weight:500;
|
|
21
|
+
letter-spacing:0;line-height:1.6;color:#1f9d6b;background:#e8f6ef;border:1px solid #bfe8d4;
|
|
22
|
+
text-decoration:none;white-space:nowrap;transition:background .15s,color .15s}
|
|
23
|
+
header .gh-link:hover{background:#d7efe2;color:#17845a}
|
|
24
|
+
/* 顶部竖滚轮播提示条:标题右侧、操作按钮左侧的空白区 */
|
|
25
|
+
.header-ad{flex:1 1 200px;min-width:0;max-width:440px;display:flex;align-items:center;gap:8px;
|
|
26
|
+
margin:0 4px;height:30px;padding:0 10px;background:#f3fbf7;border:1px solid #d7efe2;border-radius:8px;overflow:hidden}
|
|
27
|
+
.header-ad .ad-tag{flex:none;font-size:10px;font-weight:600;color:#fff;background:#1f9d6b;
|
|
28
|
+
padding:2px 7px;border-radius:10px;letter-spacing:.5px}
|
|
29
|
+
.header-ad .ad-view{flex:1;min-width:0;height:20px;overflow:hidden;position:relative}
|
|
30
|
+
.header-ad .ad-track{display:flex;flex-direction:column;transition:transform .7s ease}
|
|
31
|
+
.header-ad .ad-item{height:20px;line-height:20px;font-size:12px;color:#2f6b50;white-space:nowrap;
|
|
32
|
+
overflow:hidden;text-overflow:ellipsis}
|
|
33
|
+
/* 右上角「更多功能」下拉 */
|
|
34
|
+
.dropdown{position:relative}
|
|
35
|
+
.dropdown-list{position:absolute;right:0;top:calc(100% + 6px);min-width:calc(100% + 26px);background:#fff;
|
|
36
|
+
border:1px solid var(--line);border-radius:10px;box-shadow:0 8px 24px rgba(20,20,40,.12);
|
|
37
|
+
padding:6px;display:flex;flex-direction:column;gap:2px;z-index:50}
|
|
38
|
+
.dropdown-list[hidden]{display:none}
|
|
39
|
+
.dd-item{display:block;width:100%;text-align:left;background:none;border:none;cursor:pointer;
|
|
40
|
+
padding:8px 10px;border-radius:8px;font-size:13px;color:var(--ink-1);font-family:inherit;white-space:nowrap}
|
|
41
|
+
.dd-item:hover{background:#f3f7ff}
|
|
42
|
+
#moreToggle .caret{font-size:10px;margin-left:2px;opacity:.7}
|
|
43
|
+
.pool-chip{display:flex;align-items:center;gap:8px;background:var(--chip);margin-left:8px;
|
|
44
|
+
color:var(--primary);border:1px solid #ddd6fb;border-radius:20px;padding:5px 12px;font-size:12px}
|
|
45
|
+
.dot{width:7px;height:7px;border-radius:50%;background:var(--ok)}
|
|
46
|
+
.dot.off{background:var(--err)}
|
|
47
|
+
main{display:grid;grid-template-columns:20% minmax(0,1fr) 440px;gap:14px;padding:14px;align-items:start}
|
|
48
|
+
@media (max-width:1280px){main{grid-template-columns:280px minmax(0,1fr);}.panel.right{grid-column:1/-1}}
|
|
49
|
+
@media (max-width:860px){main{grid-template-columns:1fr}}
|
|
50
|
+
.panel{background:var(--panel);border:1px solid var(--line);border-radius:16px;
|
|
51
|
+
box-shadow:0 6px 22px rgba(80,60,160,.05);padding:14px}
|
|
52
|
+
.panel h2{font-size:13px;margin:0 0 2px;display:flex;align-items:center;gap:6px}
|
|
53
|
+
.panel .hint{font-size:11px;color:var(--ink-3);margin:0 0 10px}
|
|
54
|
+
/* 自动暂存状态:跟在提示语右边,不占额外高度 */
|
|
55
|
+
.panel .hint.save-hint{display:flex;gap:8px;align-items:baseline;justify-content:space-between}
|
|
56
|
+
.save-state{flex:none;font-size:11px;color:var(--ink-3);white-space:nowrap;cursor:pointer}
|
|
57
|
+
.save-state.ok{color:var(--ok)}
|
|
58
|
+
.save-state.saving{color:var(--warn)}
|
|
59
|
+
.save-state.err{color:var(--err)}
|
|
60
|
+
label{display:block;font-size:11px;color:var(--ink-2);margin:10px 0 5px}
|
|
61
|
+
textarea,input,select{width:100%;border:1px solid var(--line);border-radius:10px;background:#fbfaff;
|
|
62
|
+
padding:9px 11px;font:inherit;color:var(--ink);outline:none;resize:vertical}
|
|
63
|
+
textarea:focus,input:focus,select:focus{border-color:var(--primary-2);background:#fff}
|
|
64
|
+
.row{display:flex;gap:8px;align-items:center}
|
|
65
|
+
.btn{border:0;border-radius:10px;padding:9px 14px;font:inherit;font-weight:600;cursor:pointer;
|
|
66
|
+
background:#f1eefe;color:var(--primary);transition:.15s;white-space:nowrap}
|
|
67
|
+
.btn:hover{filter:brightness(.97)}
|
|
68
|
+
.btn.primary{background:linear-gradient(135deg,var(--primary),var(--pink));color:#fff;
|
|
69
|
+
box-shadow:0 6px 16px rgba(106,90,224,.28)}
|
|
70
|
+
.btn.ghost{background:#fff;border:1px solid var(--line);color:var(--ink-2)}
|
|
71
|
+
.btn.stop{background:#fff;border:1px solid #f2c6c6;color:var(--err)}
|
|
72
|
+
.btn.stop:hover:not(:disabled){background:#fff2f2}
|
|
73
|
+
.btn:disabled{opacity:.55;cursor:not-allowed}
|
|
74
|
+
.btn.full{width:100%;margin-top:10px}
|
|
75
|
+
.section-title{display:flex;align-items:center;gap:8px;margin:12px 0 6px;font-size:11px;color:var(--ink-2)}
|
|
76
|
+
.section-title b{color:var(--ink);font-size:12px}
|
|
77
|
+
.section-title .tag{margin-left:auto;color:var(--ink-3);font-weight:400}
|
|
78
|
+
.scroller{max-height:188px;overflow:auto;border:1px solid var(--line);border-radius:12px;padding:8px;background:#fdfcff}
|
|
79
|
+
.scroller::-webkit-scrollbar{width:8px}
|
|
80
|
+
.scroller::-webkit-scrollbar-thumb{background:#ded8f4;border-radius:8px}
|
|
81
|
+
.grid-styles{display:grid;grid-template-columns:repeat(auto-fill,minmax(136px,1fr));gap:10px}
|
|
82
|
+
.tile{border:2px solid transparent;background:#fff;border-radius:12px;padding:4px;cursor:pointer;
|
|
83
|
+
display:flex;flex-direction:column;gap:4px;transition:.12s;overflow:hidden}
|
|
84
|
+
.tile:hover{border-color:#cfc6f7;transform:translateY(-1px)}
|
|
85
|
+
.tile.sel{border-color:var(--primary);box-shadow:0 0 0 3px rgba(106,90,224,.15)}
|
|
86
|
+
/* 选择弹窗里当前生效的那一张固定排第一,右上角挂个「当前」小标 */
|
|
87
|
+
#pickerGrid .tile{position:relative}
|
|
88
|
+
#pickerGrid .tile .cur{position:absolute;right:7px;top:7px;background:var(--primary);color:#fff;
|
|
89
|
+
font-style:normal;font-size:10px;line-height:1.5;border-radius:6px;padding:0 5px;
|
|
90
|
+
box-shadow:0 2px 6px rgba(76,58,180,.35)}
|
|
91
|
+
.tile img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:8px;background:#f6f5fb;display:block}
|
|
92
|
+
.tile span{font-size:11.5px;color:var(--ink-2);text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
93
|
+
/* 图型 / 风格 / 主题色 + 参考图:用 flex 排,宁可让参考图换到下一行铺满,
|
|
94
|
+
也不把前三张卡挤成 155px —— 挤到那个宽度,缩略图和「更换」会叠在一起 */
|
|
95
|
+
.pick-row{display:flex;flex-wrap:wrap;gap:12px;margin:10px 0 2px}
|
|
96
|
+
.pick-row .pick-card{flex:1 1 210px;min-width:0;max-width:360px}
|
|
97
|
+
/* 参考图单独占一行时铺满整行,看起来才像一块上传区,不是半截卡片 */
|
|
98
|
+
#cardRef{flex:1 1 190px;max-width:none}
|
|
99
|
+
.pick-card{position:relative;border:1px solid var(--line);border-radius:12px;padding:10px 12px 11px;
|
|
100
|
+
background:#fff;box-shadow:0 2px 10px rgba(80,60,160,.04);transition:.15s}
|
|
101
|
+
.pick-card.on{border-color:#e6ddff;box-shadow:0 3px 14px rgba(106,90,224,.09)}
|
|
102
|
+
.pick-card [hidden]{display:none!important}
|
|
103
|
+
.pk-head{display:flex;align-items:center;gap:7px;margin-bottom:9px}
|
|
104
|
+
.pk-head .pk-ic{display:flex;color:var(--primary)}
|
|
105
|
+
.pk-head .pk-ic svg{width:15px;height:15px}
|
|
106
|
+
.pk-head b{font-size:12.5px;font-weight:600;color:var(--ink);letter-spacing:.2px}
|
|
107
|
+
.pk-head .pk-opt{margin-left:auto;font-size:11px;color:var(--ink-3)}
|
|
108
|
+
/* 空态:虚线按钮 */
|
|
109
|
+
.pk-btn{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;width:100%;
|
|
110
|
+
border:1px dashed #d7d0ef;border-radius:10px;background:#fcfbff;color:var(--ink-2);
|
|
111
|
+
padding:16px 10px;cursor:pointer;font:inherit;transition:.15s;min-height:76px}
|
|
112
|
+
.pk-btn:hover{border-color:var(--primary-2);background:#f8f6ff;color:var(--primary)}
|
|
113
|
+
.pk-btn svg{width:19px;height:19px}
|
|
114
|
+
.pk-btn .t{font-size:12px;font-weight:600;display:flex;align-items:baseline;gap:4px}
|
|
115
|
+
.pk-btn .t em{font-style:normal;font-weight:400;font-size:11px;color:var(--ink-3)}
|
|
116
|
+
.pk-btn:hover .t em{color:var(--primary-2)}
|
|
117
|
+
/* 已选态:缩略图 + 编号/名称 + 更换 + 清除 */
|
|
118
|
+
.pk-body{display:flex;align-items:center;gap:10px;min-height:76px;padding:2px 0}
|
|
119
|
+
.pk-thumb{flex:0 0 auto;width:56px;height:56px;border-radius:9px;overflow:hidden;background:#f6f5fb;
|
|
120
|
+
border:1px solid var(--line);display:flex;align-items:center;justify-content:center}
|
|
121
|
+
.pk-thumb img{width:100%;height:100%;object-fit:cover;display:block}
|
|
122
|
+
.pk-thumb .ph{font-size:10px;color:var(--ink-3)}
|
|
123
|
+
.pk-meta{flex:1;min-width:0;overflow:hidden;display:flex;flex-direction:column;gap:3px}
|
|
124
|
+
.pk-id{font-size:11px;color:#7b74a0;letter-spacing:.4px;font-variant-numeric:tabular-nums}
|
|
125
|
+
.pk-name{font-size:12.5px;font-weight:600;color:var(--ink);line-height:1.34;word-break:break-word;
|
|
126
|
+
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
127
|
+
.pk-acts{flex:0 0 auto;display:flex;flex-direction:column;align-items:center;gap:7px}
|
|
128
|
+
.pk-change{border:1px solid var(--line);background:#fff;color:var(--ink-2);border-radius:7px;
|
|
129
|
+
font:inherit;font-size:11px;padding:3px 9px;cursor:pointer;transition:.12s;white-space:nowrap}
|
|
130
|
+
.pk-change:hover{color:var(--primary);border-color:#cfc6f7;background:#faf8ff}
|
|
131
|
+
.pk-clear{border:0;background:transparent;color:#b9b3cc;font-size:15px;line-height:1;cursor:pointer;padding:0 3px}
|
|
132
|
+
.pk-clear:hover{color:var(--err)}
|
|
133
|
+
@media (max-width:860px){.pick-row .pick-card{flex:1 1 100%;max-width:none}}
|
|
134
|
+
/* 参考图:拖进来高亮,缩略图按比例完整显示(裁掉边就看不出是同一张了) */
|
|
135
|
+
#cardRef.drag{border-color:var(--primary);background:#f8f6ff;
|
|
136
|
+
box-shadow:0 0 0 3px rgba(106,90,224,.12)}
|
|
137
|
+
#cardRef .pk-thumb{background:#fff}
|
|
138
|
+
#cardRef .pk-thumb img{object-fit:contain}
|
|
139
|
+
#cardRef .pk-opt{color:var(--ink-3)}
|
|
140
|
+
/* 参数区 / 分镜清单 */
|
|
141
|
+
.params{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:12px}
|
|
142
|
+
.plabel{font-size:11px;color:var(--ink-3);flex:0 0 auto}
|
|
143
|
+
.seg{display:inline-flex;border:1px solid var(--line);border-radius:9px;overflow:hidden;background:#fff}
|
|
144
|
+
.seg button{border:0;background:#fff;color:var(--ink-2);font:inherit;font-size:11.5px;padding:5px 9px;cursor:pointer}
|
|
145
|
+
.seg button+button{border-left:1px solid var(--line)}
|
|
146
|
+
.seg button.on{background:var(--primary);color:#fff}
|
|
147
|
+
.cbx{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--ink-2);cursor:pointer}
|
|
148
|
+
.cbx input{width:auto;margin:0;accent-color:var(--primary)}
|
|
149
|
+
.stepbar{display:flex;align-items:center;gap:8px;margin:16px 0 7px}
|
|
150
|
+
.stepbar b{font-size:12.5px}
|
|
151
|
+
.stepbar .hint{margin:0}
|
|
152
|
+
.stepbar .tag{font-size:11px;color:var(--ink-3)}
|
|
153
|
+
.spacer{flex:1}
|
|
154
|
+
.step-no{width:18px;height:18px;border-radius:50%;background:var(--primary);color:#fff;
|
|
155
|
+
font-size:11px;display:flex;align-items:center;justify-content:center;font-weight:600;flex:0 0 auto}
|
|
156
|
+
.instr{border:1px solid var(--line);border-radius:10px;background:#fbfaff;padding:9px 11px;
|
|
157
|
+
font-size:11.5px;line-height:1.65;color:var(--ink-2);word-break:break-word}
|
|
158
|
+
.instr:focus{outline:none;border-color:var(--primary-2);background:#fff}
|
|
159
|
+
.items{display:flex;flex-direction:column;gap:9px}
|
|
160
|
+
.item{border:1px solid var(--line);border-radius:12px;padding:9px 10px;background:#fff;
|
|
161
|
+
box-shadow:0 2px 8px rgba(80,60,160,.03)}
|
|
162
|
+
.it-top{display:flex;align-items:center;gap:8px}
|
|
163
|
+
.it-no{flex:0 0 auto;font-size:11px;font-weight:500;color:var(--ink-3);background:#f4f2fb;
|
|
164
|
+
border-radius:6px;padding:2px 6px;font-variant-numeric:tabular-nums;letter-spacing:.3px}
|
|
165
|
+
.it-title{flex:1;padding:6px 9px;font-size:12.5px;font-weight:600}
|
|
166
|
+
.it-del{flex:0 0 auto;border:0;background:transparent;color:var(--ink-3);font-size:16px;
|
|
167
|
+
line-height:1;cursor:pointer;padding:2px 6px;border-radius:6px}
|
|
168
|
+
.it-del:hover{color:var(--err);background:#fff2f2}
|
|
169
|
+
.it-body{margin-top:7px;font-size:12px;line-height:1.65;min-height:38px;max-height:62px;
|
|
170
|
+
resize:none;overflow-y:hidden;scrollbar-width:thin}
|
|
171
|
+
.it-body::-webkit-scrollbar{width:6px}
|
|
172
|
+
.it-body::-webkit-scrollbar-thumb{background:#dcd6ee;border-radius:3px}
|
|
173
|
+
.it-body::-webkit-scrollbar-track{background:transparent}
|
|
174
|
+
.it-scene{margin-top:7px;font-size:11.5px;padding:6px 9px;background:#fbfaff;color:#4a4463}
|
|
175
|
+
.it-scene::placeholder{color:#a9a3c0}
|
|
176
|
+
.it-pw{margin-top:7px;border-top:1px dashed var(--line);padding-top:7px}
|
|
177
|
+
.it-ph{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--ink-3);margin-bottom:5px}
|
|
178
|
+
.it-ph span{flex:1}
|
|
179
|
+
.mini{border:1px solid var(--line);background:#fff;color:var(--ink-2);border-radius:7px;
|
|
180
|
+
font:inherit;font-size:11px;padding:2px 8px;cursor:pointer}
|
|
181
|
+
.mini:hover{color:var(--primary);border-color:#cfc6f7}
|
|
182
|
+
/* 出图按钮用主题色描边,和复制/重拼区分:这是真会烧额度的动作 */
|
|
183
|
+
.it-gen{color:var(--primary);border-color:#cfc6f7;font-weight:600;flex:0 0 auto}
|
|
184
|
+
.it-gen:hover{background:#f6f2ff}
|
|
185
|
+
.it-gen:disabled{opacity:.5;cursor:wait}
|
|
186
|
+
.it-prompt{font-size:11.5px;line-height:1.62;color:#4a4463;background:#fbfaff;min-height:132px;
|
|
187
|
+
max-height:460px;overflow:auto}
|
|
188
|
+
/* 还没写提示词时矮一点,写上去再自动撑开 */
|
|
189
|
+
.it-pw.blank .it-prompt{min-height:64px;height:64px}
|
|
190
|
+
.empty-items{border:1px dashed #d7d0ef;border-radius:10px;padding:14px 12px;text-align:center;
|
|
191
|
+
font-size:11.5px;color:var(--ink-3);line-height:1.75}
|
|
192
|
+
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:6px}
|
|
193
|
+
.chip{font-size:11px;border:1px solid var(--line);background:#fff;color:var(--ink-2);border-radius:14px;
|
|
194
|
+
padding:2px 9px;cursor:pointer}
|
|
195
|
+
.chip.on{background:var(--primary);border-color:var(--primary);color:#fff}
|
|
196
|
+
.chip .n{display:inline-block;margin-left:4px;padding:0 5px;border-radius:8px;background:#f0ecfb;
|
|
197
|
+
color:var(--ink-3);font-size:10px}
|
|
198
|
+
.chip.on .n{background:rgba(255,255,255,.24);color:#fff}
|
|
199
|
+
/* 情绪标签单独放大一档:这排是高频点击项,11px 太小点不准。
|
|
200
|
+
用等分网格铺满一行,容器宽度变化不会掉出孤儿标签 */
|
|
201
|
+
#moodChips{display:grid;grid-template-columns:repeat(14,minmax(0,1fr));gap:7px;margin:12px 0 2px}
|
|
202
|
+
#moodChips .chip{width:100%;text-align:center;font-size:15px;font-weight:500;
|
|
203
|
+
padding:10px 0;border-radius:20px;letter-spacing:.3px;transition:.14s}
|
|
204
|
+
#moodChips .chip:hover{border-color:#cfc6f7;color:var(--primary);background:#faf8ff}
|
|
205
|
+
#moodChips .chip.on{box-shadow:0 2px 8px rgba(106,90,224,.22)}
|
|
206
|
+
/* 面板窄了就折成两行 7 列,别把字挤到贴边 */
|
|
207
|
+
#studioPanel{container-type:inline-size}
|
|
208
|
+
@container (max-width:660px){#moodChips{grid-template-columns:repeat(7,minmax(0,1fr))}}
|
|
209
|
+
@media (max-width:768px){#moodChips{grid-template-columns:repeat(7,minmax(0,1fr))}}
|
|
210
|
+
/* 拍摄类型:预设照搬 handraw-style 的 10 个,点一下填进输入框 */
|
|
211
|
+
#shootChips{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:6px;margin:10px 0 2px}
|
|
212
|
+
#shootChips .chip{width:100%;text-align:center;font-size:12.5px;padding:7px 0;border-radius:14px}
|
|
213
|
+
#shootChips .chip:hover{border-color:#cfc6f7;color:var(--primary);background:#faf8ff}
|
|
214
|
+
@container (max-width:660px){#shootChips{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
|
215
|
+
@media (max-width:768px){#shootChips{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
|
216
|
+
.panel.left #docTitle{font-size:14px;font-weight:600;padding:8px 10px}
|
|
217
|
+
.panel.left #docBody{min-height:240px;line-height:1.7}
|
|
218
|
+
.btn.mini{padding:5px 10px;font-size:11.5px}
|
|
219
|
+
dialog.picker{width:min(1240px,96vw)}
|
|
220
|
+
dialog.picker h3 span{font-weight:400;color:var(--ink-3);font-size:12px}
|
|
221
|
+
#pickerGrid{grid-template-columns:repeat(auto-fill,minmax(184px,1fr));gap:14px}
|
|
222
|
+
#pickerGrid .tile span{font-size:12.5px}
|
|
223
|
+
#pickerGrid.color-grid{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))}
|
|
224
|
+
#pickerGrid.color-grid .tile{padding:8px;gap:6px}
|
|
225
|
+
#pickerGrid.color-grid .tile img{border-radius:50%;aspect-ratio:1}
|
|
226
|
+
.preview-card{border:1px solid var(--line);border-radius:12px;padding:12px;background:#fff;min-height:150px}
|
|
227
|
+
.preview-card h3{margin:0 0 8px;font-size:14px;line-height:1.45}
|
|
228
|
+
.preview-card p{margin:0;white-space:pre-wrap;color:#3c3652;font-size:12.5px}
|
|
229
|
+
.empty{color:var(--ink-3);font-size:12px}
|
|
230
|
+
.md{margin-top:10px;font-family:ui-monospace,"Cascadia Code",Consolas,monospace;font-size:12px;
|
|
231
|
+
color:#4a4463;background:#fbfaff;border:1px dashed var(--line);border-radius:10px;padding:10px;
|
|
232
|
+
white-space:pre-wrap;max-height:160px;overflow:auto}
|
|
233
|
+
.phone{width:100%;max-width:400px;margin:0 auto;border-radius:22px;border:6px solid #efeafc;
|
|
234
|
+
background:#fff;box-shadow:0 14px 34px rgba(80,60,160,.14);overflow:hidden}
|
|
235
|
+
.phone .bar{height:26px;display:flex;align-items:center;justify-content:center;font-size:10px;color:#9a94b2;background:#fff}
|
|
236
|
+
/* 预览区正文可滚:长文不再被 -webkit-line-clamp 截断。
|
|
237
|
+
这里只用 ::-webkit-scrollbar(6px),不要加 scrollbar-width:thin ——
|
|
238
|
+
Chrome 会优先用它,thin 实际占 10px,比自绘的还宽 */
|
|
239
|
+
.phone .body{padding:0 12px 14px;max-height:min(72vh,760px);overflow-y:auto}
|
|
240
|
+
.phone .body::-webkit-scrollbar{width:6px}
|
|
241
|
+
.phone .body::-webkit-scrollbar-thumb{background:#ded5fb;border-radius:3px}
|
|
242
|
+
.phone .body::-webkit-scrollbar-track{background:transparent}
|
|
243
|
+
.cover-wrap{position:relative}
|
|
244
|
+
/* 切换箭头放在预览图下方,不叠在图上 */
|
|
245
|
+
.cover-nav-row{display:none;align-items:center;justify-content:center;gap:14px;margin-top:8px}
|
|
246
|
+
.cover-nav-row.show{display:flex}
|
|
247
|
+
.cover-nav{width:30px;height:30px;border-radius:50%;border:1px solid var(--line);background:#fff;
|
|
248
|
+
color:var(--ink-2);font-size:17px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;
|
|
249
|
+
padding:0;flex:0 0 auto}
|
|
250
|
+
.cover-nav:hover{color:var(--primary);border-color:#cfc6f7;background:#fbfaff}
|
|
251
|
+
.cover-nav:disabled{opacity:.35;cursor:not-allowed}
|
|
252
|
+
#coverCount{font-size:11px;color:var(--ink-2);min-width:34px;text-align:center}
|
|
253
|
+
.phone .cover{width:100%;aspect-ratio:var(--cover-ar,3 / 4);border-radius:10px;background:linear-gradient(135deg,#efeafd,#ffe9f1);
|
|
254
|
+
display:flex;align-items:center;justify-content:center;color:#a49dc0;font-size:11px;overflow:hidden}
|
|
255
|
+
.phone .cover img{width:100%;height:100%;object-fit:cover}
|
|
256
|
+
.phone h4{margin:10px 0 6px;font-size:12.5px;line-height:1.5}
|
|
257
|
+
.phone p{margin:0;font-size:11px;line-height:1.7;color:#4b4562;white-space:pre-wrap}
|
|
258
|
+
.phone .tags{margin-top:8px;font-size:10.5px;color:#6d63d4}
|
|
259
|
+
.status{margin-top:10px;font-size:11px;color:var(--ink-2);min-height:18px}
|
|
260
|
+
.status.run{color:var(--warn)} .status.ok{color:var(--ok)} .status.err{color:var(--err)}
|
|
261
|
+
.split{display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center}
|
|
262
|
+
dialog{border:0;border-radius:14px;padding:0;width:min(680px,92vw);box-shadow:0 20px 50px rgba(40,20,100,.25)}
|
|
263
|
+
dialog::backdrop{background:rgba(35,25,70,.35)}
|
|
264
|
+
/* 弹窗期间锁住背景滚动,弹窗内滚到顶/底也不把滚动链传给下面的页面 */
|
|
265
|
+
html.modal-lock, html.modal-lock body{overflow:hidden;overscroll-behavior:contain}
|
|
266
|
+
dialog[open], dialog[open] *{overscroll-behavior:contain}
|
|
267
|
+
dialog .inner{padding:16px}
|
|
268
|
+
dialog h3{margin:0 0 8px;font-size:14px}
|
|
269
|
+
dialog pre{white-space:pre-wrap;background:#f7f5ff;border-radius:10px;padding:12px;font-size:12px;max-height:58vh;overflow:auto}
|
|
270
|
+
.toast{position:fixed;right:18px;bottom:18px;z-index:90;display:flex;align-items:flex-start;gap:9px;
|
|
271
|
+
background:#2b2646;color:#fff;border-radius:12px;padding:10px 14px 10px 11px;font-size:12px;
|
|
272
|
+
opacity:0;transform:translateY(8px);transition:.25s;pointer-events:none;max-width:380px;
|
|
273
|
+
box-shadow:0 12px 30px rgba(30,18,60,.28)}
|
|
274
|
+
.toast.show{opacity:1;transform:none;pointer-events:auto;cursor:pointer}
|
|
275
|
+
.toast::before{content:'';flex:0 0 auto;width:18px;height:18px;border-radius:50%;margin-top:1px;
|
|
276
|
+
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;
|
|
277
|
+
background:rgba(255,255,255,.16);color:#fff}
|
|
278
|
+
.toast.ok::before{content:'✓';background:#2ea36a}
|
|
279
|
+
.toast.err::before{content:'!';background:#d8455f}
|
|
280
|
+
.toast.info::before{content:'i';background:#6a5ae0}
|
|
281
|
+
.toast:not(.ok):not(.err):not(.info)::before{display:none}
|
|
282
|
+
.toast .t-tx{min-width:0}
|
|
283
|
+
.toast b{display:block;font-size:12.5px;font-weight:600;line-height:1.45;margin-bottom:2px}
|
|
284
|
+
.toast b[hidden]{display:none}
|
|
285
|
+
.toast span{display:block;line-height:1.55;color:rgba(255,255,255,.82);word-break:break-word}
|
|
286
|
+
.pill{display:inline-flex;align-items:center;gap:6px;background:#f7f5ff;border:1px solid var(--line);
|
|
287
|
+
border-radius:12px;padding:3px 9px;font-size:11px;color:var(--ink-2)}
|
|
288
|
+
.footer-note{margin-top:12px;font-size:10.5px;color:var(--ink-3);line-height:1.6}
|
|
289
|
+
.strip-wrap{display:flex;align-items:center;gap:6px}
|
|
290
|
+
.strip{position:relative;flex:1;display:flex;gap:8px;overflow-x:auto;padding:6px 2px;min-height:100px;scroll-behavior:smooth}
|
|
291
|
+
.strip::-webkit-scrollbar{height:8px}
|
|
292
|
+
.strip::-webkit-scrollbar-thumb{background:#ded8f4;border-radius:8px}
|
|
293
|
+
.shot{flex:0 0 88px;border:2px solid transparent;border-radius:10px;padding:2px;background:#fff;
|
|
294
|
+
cursor:pointer;position:relative;transition:.12s}
|
|
295
|
+
.shot img{width:100%;height:92px;object-fit:cover;border-radius:8px;display:block;background:#f6f5fb;cursor:zoom-in}
|
|
296
|
+
.shot.on{border-color:var(--primary);box-shadow:0 0 0 3px rgba(106,90,224,.16)}
|
|
297
|
+
.shot .no{position:absolute;left:5px;top:5px;background:rgba(31,27,46,.62);color:#fff;
|
|
298
|
+
font-size:10px;border-radius:6px;padding:0 5px}
|
|
299
|
+
.dl-row{display:none;gap:8px;margin:10px 0}
|
|
300
|
+
.dl-row.show{display:flex}
|
|
301
|
+
.dl-row .btn{flex:1 1 0;min-width:0}
|
|
302
|
+
.dl-row .btn:disabled{opacity:.5;cursor:wait}
|
|
303
|
+
.nav-btn{flex:0 0 28px;height:58px;border-radius:9px;border:1px solid var(--line);background:#fff;
|
|
304
|
+
color:var(--ink-2);cursor:pointer;font-size:15px;line-height:1}
|
|
305
|
+
.nav-btn:hover{color:var(--primary);border-color:#cfc6f7}
|
|
306
|
+
.nav-btn:disabled{opacity:.4;cursor:not-allowed}
|
|
307
|
+
#cover img{width:100%;height:100%;object-fit:cover;cursor:zoom-in}
|
|
308
|
+
/* 必须 fit-content,不能写 auto:auto 会盖掉 UA 的 width:fit-content,
|
|
309
|
+
弹窗被拉伸到 max-width(96vw),图就变成贴左边了 */
|
|
310
|
+
dialog.viewer{width:fit-content;max-width:96vw;background:transparent;box-shadow:none;overflow:visible}
|
|
311
|
+
dialog.viewer::backdrop{background:rgba(18,12,38,.8)}
|
|
312
|
+
/* 标题比图宽时弹窗按标题撑开,图自己水平居中,不至于又贴左 */
|
|
313
|
+
dialog.viewer img{max-width:94vw;max-height:86vh;border-radius:14px;display:block;margin:0 auto}
|
|
314
|
+
/* 按钮放到图片外侧,不压在画面上:三列 flex,中间是图 */
|
|
315
|
+
/* overflow:hidden 让放大后的图只在画框内走动,不会糊住底部说明和翻页按钮 */
|
|
316
|
+
.viewer-stage{display:flex;align-items:center;justify-content:center;gap:18px;overflow:hidden;border-radius:14px}
|
|
317
|
+
/* 图片给两侧按钮让出位置,避免按钮顶出屏幕;只有一张图(按钮隐藏)时不缩图 */
|
|
318
|
+
dialog.viewer .viewer-stage img{max-width:94vw;margin:0}
|
|
319
|
+
.viewer-stage:has(.viewer-nav:not([hidden])) img{max-width:calc(94vw - 150px)}
|
|
320
|
+
/* 左右切图:圆形按钮,只有一张图时隐藏 */
|
|
321
|
+
.viewer-nav{flex:0 0 auto;width:48px;height:48px;border-radius:50%;
|
|
322
|
+
border:1px solid rgba(255,255,255,.5);cursor:pointer;color:#fff;padding:0;position:relative;z-index:2;
|
|
323
|
+
background:rgba(16,11,32,.78);backdrop-filter:blur(8px);box-shadow:0 6px 18px rgba(0,0,0,.45);
|
|
324
|
+
display:flex;align-items:center;justify-content:center;transition:background .15s,transform .12s}
|
|
325
|
+
.viewer-nav:hover{background:rgba(31,157,107,.92);border-color:rgba(255,255,255,.7)}
|
|
326
|
+
.viewer-nav:active{transform:scale(.94)}
|
|
327
|
+
.viewer-nav[hidden]{display:none}
|
|
328
|
+
@media (max-width:640px){
|
|
329
|
+
.viewer-stage{gap:6px}
|
|
330
|
+
.viewer-nav{width:34px;height:34px}
|
|
331
|
+
.viewer-nav svg{width:18px;height:18px}
|
|
332
|
+
.viewer-stage:has(.viewer-nav:not([hidden])) img{max-width:calc(96vw - 88px)}
|
|
333
|
+
}
|
|
334
|
+
.viewer-cap{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:10px;color:#fff;font-size:12px;
|
|
335
|
+
position:relative;z-index:3}
|
|
336
|
+
/* 大图:滚轮放大缩小,放大后可以拖着看;缩小回 1 倍自动回正 */
|
|
337
|
+
dialog.viewer img{cursor:zoom-in;will-change:transform;user-select:none;-webkit-user-drag:none}
|
|
338
|
+
dialog.viewer.zoomed img{cursor:grab}
|
|
339
|
+
dialog.viewer.zoomed img.dragging{cursor:grabbing}
|
|
340
|
+
#viewZoom{color:#9fe8c6;font-size:11.5px;font-variant-numeric:tabular-nums;min-width:34px;text-align:center}
|
|
341
|
+
.viewer-cap button, .viewer-cap a.dl{border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.12);
|
|
342
|
+
color:#fff;border-radius:9px;padding:5px 12px;cursor:pointer;font:inherit;text-decoration:none}
|
|
343
|
+
/* 顶栏入口 */
|
|
344
|
+
/* 顶栏操作区:出图三连放到右上角跟着页面走,不用滚到底部去点 */
|
|
345
|
+
.head-acts{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:nowrap}
|
|
346
|
+
.head-acts .btn{padding:8px 13px;font-size:12.5px}
|
|
347
|
+
.head-acts .btn.mini{padding:6px 11px;font-size:11.5px}
|
|
348
|
+
.head-acts .status{margin:0;max-width:220px;overflow:hidden;text-overflow:ellipsis;
|
|
349
|
+
white-space:nowrap;font-size:11.5px;line-height:1.3}
|
|
350
|
+
.head-acts .ha-sep{width:1px;height:20px;background:var(--line);margin:0 3px;flex:none}
|
|
351
|
+
/* 空间不够时:提示条留在标题同一行的右侧空档,操作区独占第二行,别把标题挤变形 */
|
|
352
|
+
@media (max-width:1360px){
|
|
353
|
+
header{flex-wrap:wrap;row-gap:8px}
|
|
354
|
+
.header-ad{order:1;flex:1 1 160px}
|
|
355
|
+
.head-acts{order:2;flex:1 0 100%;margin-left:0;flex-wrap:wrap;justify-content:flex-start}
|
|
356
|
+
.head-acts .pool-chip{margin-left:auto}
|
|
357
|
+
}
|
|
358
|
+
/* 宽弹窗 */
|
|
359
|
+
dialog.wide{width:min(1120px,95vw);max-height:calc(100vh - 32px);overflow:auto}
|
|
360
|
+
/* 历史弹窗:高度固定,头部/列表/分页各占各的,列表自己滚,整个弹窗不滚。
|
|
361
|
+
高度写死是为了展开任务时框不跟着蹦,眼睛不用重新找位置 */
|
|
362
|
+
#historyDlg{height:min(940px,calc(100vh - 40px));max-height:calc(100vh - 40px);overflow:hidden}
|
|
363
|
+
#historyDlg .inner{height:100%;display:flex;flex-direction:column;min-height:0}
|
|
364
|
+
#historyDlg .dlg-head{flex:none}
|
|
365
|
+
.dlg-head{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}
|
|
366
|
+
.dlg-head h3{margin:0;font-size:14px;display:flex;align-items:baseline;gap:8px}
|
|
367
|
+
.dlg-head h3 span{font-size:11px;color:var(--ink-3);font-weight:400}
|
|
368
|
+
.dlg-head .spacer{flex:1}
|
|
369
|
+
.dlg-head input{width:190px;padding:6px 10px;font-size:12px;flex:none}
|
|
370
|
+
/* 全局有 select{width:100%},放头部会占满整行把其它控件挤下去 */
|
|
371
|
+
.dlg-head select{width:auto;flex:none;padding:6px 8px;font-size:12px}
|
|
372
|
+
/* 历史卡片 */
|
|
373
|
+
.hist-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(188px,1fr));gap:12px;
|
|
374
|
+
max-height:64vh;overflow:auto;padding:2px}
|
|
375
|
+
.hist-card{border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden;
|
|
376
|
+
display:flex;flex-direction:column}
|
|
377
|
+
.hist-card .hc-thumb{position:relative;aspect-ratio:3/4;background:#f6f5fb;display:flex;
|
|
378
|
+
align-items:center;justify-content:center;overflow:hidden}
|
|
379
|
+
.hist-card .hc-thumb img{width:100%;height:100%;object-fit:cover;display:block;cursor:zoom-in}
|
|
380
|
+
.hist-card .hc-thumb.failed{color:var(--err);font-size:11px;padding:10px;text-align:center;line-height:1.6}
|
|
381
|
+
.hist-card .hc-thumb.text{color:var(--ink-2);font-size:11px;padding:11px;line-height:1.7;text-align:left;
|
|
382
|
+
align-items:flex-start;justify-content:flex-start;white-space:pre-wrap;overflow:hidden}
|
|
383
|
+
.hist-card .hc-body{padding:8px 9px 9px;display:flex;flex-direction:column;gap:4px;flex:1}
|
|
384
|
+
.hist-card b{font-size:12px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;
|
|
385
|
+
-webkit-box-orient:vertical;overflow:hidden}
|
|
386
|
+
.hist-card .hc-sub{font-size:10.5px;color:var(--ink-3);line-height:1.55}
|
|
387
|
+
.hist-card .hc-acts{display:flex;gap:5px;margin-top:auto;padding-top:6px}
|
|
388
|
+
.hist-card .hc-acts .mini{flex:1;text-align:center}
|
|
389
|
+
.hist-empty{padding:34px;text-align:center;color:var(--ink-3);font-size:12px}
|
|
390
|
+
.hist-empty[hidden]{display:none}
|
|
391
|
+
.hist-more{display:flex;justify-content:center;margin-top:10px}
|
|
392
|
+
.hist-more button{display:none}
|
|
393
|
+
.hist-more button.show{display:inline-block}
|
|
394
|
+
/* 历史:按任务折叠 */
|
|
395
|
+
/* 列表吃掉弹窗剩余高度,随窗口伸缩;不再用 64vh 卡死,展开态才有地方长 */
|
|
396
|
+
.task-list{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:8px;padding:2px}
|
|
397
|
+
/* 行不裁内容:原来 overflow:hidden 会把卡片底部 4px 切掉,按钮看着像被挡住 */
|
|
398
|
+
.task-row{border:1px solid var(--line);border-radius:12px;background:#fff;overflow:visible}
|
|
399
|
+
.task-row.open{border-color:#d8cffb;box-shadow:0 3px 12px rgba(106,90,224,.07)}
|
|
400
|
+
.task-head{display:flex;align-items:center;gap:9px;padding:9px 11px;cursor:pointer;user-select:none;
|
|
401
|
+
border-radius:11px}
|
|
402
|
+
.task-row.open .task-head{border-radius:11px 11px 0 0}
|
|
403
|
+
.task-head:hover{background:#faf8ff}
|
|
404
|
+
.task-head .tw{width:11px;flex:none;color:var(--ink-3);font-size:10px;transition:transform .15s;display:inline-block}
|
|
405
|
+
.task-row.open .task-head .tw{transform:rotate(90deg)}
|
|
406
|
+
.task-head b{font-size:12.5px;line-height:1.45;overflow:hidden;text-overflow:ellipsis;
|
|
407
|
+
white-space:nowrap;max-width:44%;font-weight:600}
|
|
408
|
+
.task-head .tm{font-size:11px;color:var(--ink-3);white-space:nowrap}
|
|
409
|
+
.task-head .tm .okc{color:var(--ok)} .task-head .tm .errc{color:var(--err)}
|
|
410
|
+
.task-head .spacer{flex:1;min-width:6px}
|
|
411
|
+
.task-head .chipx{font-size:10.5px;color:var(--ink-3);border:1px solid var(--line);
|
|
412
|
+
border-radius:20px;padding:1px 7px;white-space:nowrap;background:#fbfaff}
|
|
413
|
+
.task-head .chipx.img{color:var(--primary);border-color:#ded5fb;background:#f7f4ff}
|
|
414
|
+
/* 任务行右侧两个动作:载入 / 删除。别让它们把标题挤没,标题那边靠 ellipsis 兜 */
|
|
415
|
+
.task-head .tacts{display:flex;gap:5px;flex:none;margin-left:2px}
|
|
416
|
+
.task-head .tacts .mini{padding:3px 8px;font-size:10.5px;line-height:1.45;white-space:nowrap}
|
|
417
|
+
.task-head .tacts .mini.del:hover{color:var(--err);border-color:#f2c6c6}
|
|
418
|
+
.task-body{border-top:1px dashed var(--line);padding:10px 10px 14px;background:#fcfbff;
|
|
419
|
+
border-radius:0 0 11px 11px}
|
|
420
|
+
.task-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:9px}
|
|
421
|
+
.task-grid .hist-card .hc-body{padding:6px 7px 7px;gap:2px}
|
|
422
|
+
.task-grid .hist-card b{font-size:11.5px;-webkit-line-clamp:2}
|
|
423
|
+
.task-grid .hist-card .hc-sub{font-size:10px}
|
|
424
|
+
.task-grid .hist-card .hc-acts{padding-top:5px;gap:4px}
|
|
425
|
+
.task-grid .hist-card .hc-acts .mini{padding:4px 6px;font-size:10.5px}
|
|
426
|
+
.task-empty{padding:14px;text-align:center;color:var(--ink-3);font-size:11.5px}
|
|
427
|
+
.pager{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:11.5px;color:var(--ink-3);flex:none}
|
|
428
|
+
.pager .spacer{flex:1}
|
|
429
|
+
.pager button{border:1px solid var(--line);background:#fff;color:var(--ink-2);border-radius:9px;
|
|
430
|
+
padding:5px 11px;font:inherit;font-size:11.5px;cursor:pointer}
|
|
431
|
+
.pager button:disabled{opacity:.4;cursor:default}
|
|
432
|
+
.pager select{padding:4px 7px;font-size:11.5px;border-radius:8px;border:1px solid var(--line);background:#fff}
|
|
433
|
+
/* 草稿箱 */
|
|
434
|
+
.draft-list{max-height:60vh;overflow:auto;display:flex;flex-direction:column;gap:7px}
|
|
435
|
+
.draft-row{display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:11px;
|
|
436
|
+
background:#fff;padding:9px 11px}
|
|
437
|
+
.draft-row .dc{min-width:0;flex:1}
|
|
438
|
+
.draft-row b{display:block;font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
439
|
+
.draft-row .ds{display:block;font-size:10.5px;color:var(--ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
440
|
+
.draft-row .rn-input{width:100%;box-sizing:border-box;font:inherit;font-size:12.5px;
|
|
441
|
+
border:1px solid var(--primary);border-radius:7px;padding:3px 7px;background:#fff;color:inherit}
|
|
442
|
+
.draft-row .dacts{display:flex;gap:5px;flex:none}
|
|
443
|
+
.draft-row .chipx{font-size:10px;color:var(--primary);border:1px solid #ded5fb;background:#f7f4ff;
|
|
444
|
+
border-radius:20px;padding:1px 7px;white-space:nowrap;flex:none}
|
|
445
|
+
.panel h2 .spacer{flex:1}
|
|
446
|
+
.panel h2 .btn.mini{padding:4px 9px;font-size:11px}
|
|
447
|
+
.panel h2 #draftCount{font-style:normal;color:var(--primary);font-weight:600}
|
|
448
|
+
/* 监控 */
|
|
449
|
+
.mon-sum{display:grid;grid-template-columns:repeat(auto-fit,minmax(122px,1fr));gap:8px;margin-bottom:6px}
|
|
450
|
+
.mon-kpi{border:1px solid var(--line);border-radius:11px;padding:9px 11px;background:#fbfaff}
|
|
451
|
+
.mon-kpi span{display:block;font-size:10.5px;color:var(--ink-3)}
|
|
452
|
+
.mon-kpi b{font-size:17px;letter-spacing:.4px}
|
|
453
|
+
.mon-kpi b.ok{color:var(--ok)} .mon-kpi b.warn{color:var(--warn)} .mon-kpi b.err{color:var(--err)}
|
|
454
|
+
.mon-sec{display:flex;align-items:center;gap:8px;margin:12px 0 6px;font-size:12px}
|
|
455
|
+
.mon-sec span{font-size:11px;color:var(--ink-3)}
|
|
456
|
+
.mon-sec .spacer{flex:1}
|
|
457
|
+
.mon-box{border:1px solid var(--line);border-radius:11px;overflow:auto;max-height:38vh;background:#fff}
|
|
458
|
+
table.mon{width:100%;border-collapse:collapse;font-size:11.5px}
|
|
459
|
+
table.mon th{position:sticky;top:0;background:#faf8ff;color:var(--ink-3);font-weight:500;
|
|
460
|
+
text-align:left;padding:7px 9px;white-space:nowrap;border-bottom:1px solid var(--line)}
|
|
461
|
+
table.mon td{padding:5px 9px;border-bottom:1px solid #f4f2fb;white-space:nowrap;color:var(--ink-2)}
|
|
462
|
+
table.mon td.err{color:var(--err);max-width:230px;overflow:hidden;text-overflow:ellipsis}
|
|
463
|
+
table.mon td.quota{color:var(--ink-2)}
|
|
464
|
+
table.mon td.quota.low{color:#b37207;font-weight:500}
|
|
465
|
+
table.mon tr.on-air td{background:#fffaf0}
|
|
466
|
+
table.mon tr.on-air td:first-child{box-shadow:inset 2px 0 0 #e9a13b}
|
|
467
|
+
.st{padding:1px 7px;border-radius:9px;font-size:10.5px;background:#f0ecfb;color:var(--ink-2)}
|
|
468
|
+
.st.ready{background:#e7f9f1;color:#0b8a63}
|
|
469
|
+
.st.busy{background:#fff5e0;color:#b37207}
|
|
470
|
+
.st.unhealthy,.st.error{background:#ffeaea;color:#c92a2a}
|
|
471
|
+
.st.unbound,.st.pending{background:#f1f0f5;color:#7b74a0}
|
|
472
|
+
.mon-jobs{display:flex;flex-direction:column;gap:6px}
|
|
473
|
+
.mon-job{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:9px;
|
|
474
|
+
padding:6px 9px;font-size:11.5px;background:#fff}
|
|
475
|
+
.mon-job .mj-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
476
|
+
.mon-job .mj-a{color:var(--ink-3);font-size:10.5px}
|
|
477
|
+
.mon-job.fail{border-color:#ffd9d9;background:#fffafa}
|
|
478
|
+
.mon-none{font-size:11.5px;color:var(--ink-3);padding:6px 2px}
|
|
479
|
+
/* 单张重做提示条:主色描边,和默认态明确区分 */
|
|
480
|
+
.redo-bar{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:9px 12px;
|
|
481
|
+
background:#f1eefe;border:1px solid #ddd6fb;border-left:3px solid var(--primary);
|
|
482
|
+
border-radius:10px;color:var(--primary);font-size:12px;line-height:1.5}
|
|
483
|
+
.redo-bar[hidden]{display:none!important}
|
|
484
|
+
.redo-bar .spacer{flex:1}
|
|
485
|
+
/* 随机示例提示条:这批东西是系统随手给的,得说清楚,别让人以为是自己写的 */
|
|
486
|
+
.demo-note{display:flex;align-items:flex-start;gap:8px;margin:0 0 10px;padding:9px 12px;
|
|
487
|
+
background:#fffaf0;border:1px solid #f3d9ad;border-left:3px solid var(--warn);
|
|
488
|
+
border-radius:10px;color:#8a5b00;font-size:12px;line-height:1.55}
|
|
489
|
+
.demo-note[hidden]{display:none!important}
|
|
490
|
+
.demo-note b{color:#b37207;font-weight:600}
|
|
491
|
+
.demo-note .dn-x{margin-left:auto;flex:none;border:0;background:transparent;color:#b37207;
|
|
492
|
+
cursor:pointer;font-size:15px;line-height:1;padding:2px 5px;border-radius:6px}
|
|
493
|
+
.demo-note .dn-x:hover{background:#f8e8cb}
|
|
494
|
+
/* ---------- 接口与号池弹窗 ---------- */
|
|
495
|
+
/* 宽屏下用横向空白摊成两栏,只有账号列表在自身框内滚,整框不出长滚动条 */
|
|
496
|
+
#configDlg{width:min(1240px,94vw);max-height:calc(100vh - 32px);overflow:hidden}
|
|
497
|
+
#configDlg .inner{display:flex;flex-direction:column;max-height:calc(100vh - 32px);min-height:0}
|
|
498
|
+
#configDlg .dlg-head,#configDlg .cfg-tabs,#configDlg .cfg-foot{flex:none}
|
|
499
|
+
#configDlg #tabIface{flex:1 1 auto;overflow:auto;min-height:0;padding-right:4px}
|
|
500
|
+
.iface-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 14px}
|
|
501
|
+
.iface-grid > *{min-width:0}
|
|
502
|
+
.iface-grid > .span2{grid-column:1/-1}
|
|
503
|
+
.cfg-tabs{display:flex;gap:6px;background:#f6f4fd;border:1px solid var(--line);border-radius:10px;padding:4px;margin:0 0 14px}
|
|
504
|
+
.cfg-tab{flex:1;border:0;background:transparent;border-radius:8px;padding:7px 10px;font:inherit;font-weight:600;
|
|
505
|
+
color:var(--ink-3);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;transition:.15s}
|
|
506
|
+
.cfg-tab:hover{color:var(--primary)}
|
|
507
|
+
.cfg-tab.on{background:#fff;color:var(--primary);box-shadow:0 2px 8px rgba(80,60,160,.1)}
|
|
508
|
+
.cfg-tab i{font-style:normal;font-size:11px;font-weight:400;color:var(--ink-3);margin-left:4px}
|
|
509
|
+
.cfg-tab.on i{color:var(--primary)}
|
|
510
|
+
.cfg-foot{display:flex;gap:8px;align-items:center;margin-top:14px;border-top:1px solid #f0ecfa;padding-top:12px}
|
|
511
|
+
.cfg-foot[hidden]{display:none}
|
|
512
|
+
.cfg-foot .spacer{flex:1}
|
|
513
|
+
.cfg-foot #cfgResult{flex:1;min-width:0}
|
|
514
|
+
#tabPool{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:0 14px;
|
|
515
|
+
flex:0 1 auto;min-height:0;height:clamp(552px,calc(100vh - 250px),660px);overflow:hidden}
|
|
516
|
+
.pool-col{display:flex;flex-direction:column;gap:7px;min-height:0}
|
|
517
|
+
.pool-left{overflow:auto;padding-right:4px}
|
|
518
|
+
.pool-right{overflow:hidden}
|
|
519
|
+
#tabPool .pool-left #pImport{flex:1 1 auto;min-height:84px}
|
|
520
|
+
#tabPool[hidden]{display:none}
|
|
521
|
+
.pstate{display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:#faf9ff;border:1px solid var(--line);
|
|
522
|
+
border-radius:10px;padding:8px 11px;font-size:12px}
|
|
523
|
+
#pSvcMsg{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
524
|
+
.pstate b{font-size:12.5px}
|
|
525
|
+
.pstate .spacer{flex:1}
|
|
526
|
+
.pmeta{color:var(--ink-3);font-size:11.5px;line-height:1.55}
|
|
527
|
+
.pwarn{background:#fff7e6;border:1px solid #ffe0a3;color:#8a5a00;border-radius:10px;
|
|
528
|
+
padding:8px 11px;font-size:12px;line-height:1.6}
|
|
529
|
+
.pbox{border:1px solid var(--line);border-radius:12px;padding:10px 12px;background:#fdfcff;
|
|
530
|
+
display:flex;flex-direction:column;gap:7px}
|
|
531
|
+
.prow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
532
|
+
.plabel{flex:0 0 56px;color:var(--ink-3);font-size:11.5px}
|
|
533
|
+
.prow code{flex:1;min-width:0;background:#f3f1fb;border:1px solid #e8e4f5;border-radius:7px;
|
|
534
|
+
padding:4px 9px;font-size:12px;color:#2b2646;overflow:hidden;text-overflow:ellipsis;
|
|
535
|
+
white-space:nowrap;user-select:all}
|
|
536
|
+
.phint{margin:0;font-size:11px;color:var(--ink-3);line-height:1.6}
|
|
537
|
+
.pdrop{display:flex;align-items:center;gap:7px;flex:0 0 auto;
|
|
538
|
+
border:1px dashed #c9bff0;border-radius:10px;background:#fbfaff;
|
|
539
|
+
padding:3px 8px;cursor:pointer;transition:.15s}
|
|
540
|
+
.pdrop:hover,.pdrop.on{border-color:var(--primary);background:#f6f2ff}
|
|
541
|
+
.pdrop.drag{border-color:var(--primary);background:#efe9ff;box-shadow:0 0 0 3px rgba(106,90,224,.12)}
|
|
542
|
+
.pdrop-ico{flex:0 0 auto;width:20px;height:20px;border-radius:6px;background:#eee9ff;
|
|
543
|
+
display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--primary)}
|
|
544
|
+
.pdrop-hint{flex:1 1 auto;min-width:0;font-size:11.5px;color:var(--ink-2);
|
|
545
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
546
|
+
.pdrop .pmeta{flex:0 0 auto;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
547
|
+
.pdrop .btn.mini{padding:3px 9px}
|
|
548
|
+
.ptip{display:flex;align-items:flex-start;gap:6px;background:#f5f1ff;border:1px solid #e6ddfb;
|
|
549
|
+
border-radius:9px;padding:6px 9px;font-size:11px;line-height:1.45;color:#5b4a86}
|
|
550
|
+
.ptip > span:first-child{flex:0 0 auto;line-height:1.35}
|
|
551
|
+
.ptip a{color:var(--primary);text-decoration:none;border-bottom:1px dotted currentColor}
|
|
552
|
+
.ptip a:hover{border-bottom-style:solid}
|
|
553
|
+
#pHome{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
554
|
+
.psec{display:flex;align-items:baseline;gap:8px;margin-top:4px}
|
|
555
|
+
.psec b{font-size:12px}
|
|
556
|
+
.psec .tag{font-size:11px;color:var(--ink-3)}
|
|
557
|
+
.psec .tag.ok{color:var(--ok)}
|
|
558
|
+
.psec .tag.warn{color:var(--warn);font-weight:600}
|
|
559
|
+
.psec .tag.bad{color:var(--err);font-weight:600}
|
|
560
|
+
.pacc{border:1px solid var(--line);border-radius:12px;max-height:224px;overflow:auto;background:#fff}
|
|
561
|
+
#tabPool .pool-right #pAccList{flex:1 1 auto;min-height:0;max-height:none;overflow:auto}
|
|
562
|
+
.pacc-row{display:flex;align-items:center;gap:8px;padding:7px 10px;border-bottom:1px solid #f4f2fb;font-size:12px}
|
|
563
|
+
.pacc-row:last-child{border-bottom:0}
|
|
564
|
+
.pacc-mail{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
565
|
+
.pacc-num{width:58px;text-align:right;color:var(--ink-2);font-variant-numeric:tabular-nums;flex:0 0 auto}
|
|
566
|
+
.pacc-use{width:78px;text-align:right;color:var(--ink-3);font-size:11px;
|
|
567
|
+
font-variant-numeric:tabular-nums;flex:0 0 auto}
|
|
568
|
+
.pacc-act{display:flex;gap:2px;flex:0 0 auto}
|
|
569
|
+
.pacc-act button{border:0;background:transparent;color:var(--primary);font:inherit;font-size:11.5px;
|
|
570
|
+
cursor:pointer;padding:2px 5px;border-radius:6px}
|
|
571
|
+
.pacc-act button:hover{background:#f1eefe}
|
|
572
|
+
.pacc-act button.danger{color:var(--err)}
|
|
573
|
+
.pacc-empty{padding:16px;text-align:center;color:var(--ink-3);font-size:12px;line-height:1.7}
|
|
574
|
+
.pchip{flex:0 0 auto;font-size:11px;line-height:1.6;padding:0 7px;border-radius:20px;border:1px solid transparent}
|
|
575
|
+
.pchip.ok{color:#17845a;background:#e8f6ef;border-color:#bfe8d4}
|
|
576
|
+
.pchip.warn{color:#8a5a00;background:#fff7e6;border-color:#ffe0a3}
|
|
577
|
+
.pchip.err{color:#b02a2a;background:#fff0f0;border-color:#ffd0d0}
|
|
578
|
+
/* 窗口不够宽时回到单栏,内容超长才滚动 */
|
|
579
|
+
@media (max-width:900px){
|
|
580
|
+
#configDlg{width:min(780px,94vw)}
|
|
581
|
+
.iface-grid{grid-template-columns:1fr}
|
|
582
|
+
#tabPool{display:flex;flex-direction:column;gap:10px;height:auto;
|
|
583
|
+
flex:1 1 auto;overflow:auto;padding-right:4px}
|
|
584
|
+
#tabPool > .pool-col{flex:0 0 auto}
|
|
585
|
+
.pool-left{overflow:visible;padding-right:0}
|
|
586
|
+
.pool-right{overflow:visible}
|
|
587
|
+
#tabPool .pool-left #pImport{flex:0 0 auto}
|
|
588
|
+
#tabPool .pool-right #pAccList{flex:0 0 auto;max-height:224px}
|
|
589
|
+
}
|
|
590
|
+
.pchip.off{color:var(--ink-3);background:#f2f2f5;border-color:#e4e4ea}
|
|
591
|
+
</style>
|
|
592
|
+
<script>
|
|
593
|
+
/* 会话失效时统一回申请页,避免各业务 fetch 各自处理 401 */
|
|
594
|
+
(function(){
|
|
595
|
+
var raw = window.fetch;
|
|
596
|
+
window.fetch = function(){
|
|
597
|
+
return raw.apply(this, arguments).then(function(r){
|
|
598
|
+
if(r.status === 401){ location.href = '/access?expired=1'; }
|
|
599
|
+
return r;
|
|
600
|
+
});
|
|
601
|
+
};
|
|
602
|
+
})();
|
|
603
|
+
</script>
|
|
604
|
+
</head>
|
|
605
|
+
<body>
|
|
606
|
+
<header>
|
|
607
|
+
<div>
|
|
608
|
+
<h1><span id="appName">Bluxe手绘工坊 · 生图工作台</span><a class="gh-link" href="https://mp.weixin.qq.com/s/dQ48uZSEurgBD3py1YwPNQ"
|
|
609
|
+
target="_blank" rel="noopener noreferrer" title="微信公众号:Bluxe的AI办公笔记">#Bluxe的AI办公笔记</a></h1>
|
|
610
|
+
<div class="sub">选题和文案 → 拆分镜 → handraw-style 配图 → 生成提示词 → 批量出图 → 排版预览</div>
|
|
611
|
+
</div>
|
|
612
|
+
<div class="header-ad" id="headerAd" title="使用提示">
|
|
613
|
+
<span class="ad-tag">提示</span>
|
|
614
|
+
<div class="ad-view"><div class="ad-track" id="adTrack">
|
|
615
|
+
<div class="ad-item">如果不知道怎么上手,可以试着直接点击「批量出图」。</div>
|
|
616
|
+
<div class="ad-item">使用过程中有任何问题,可以多看看公众号详细介绍。</div>
|
|
617
|
+
<div class="ad-item">如果发现其他问题,估计作者也搞不定,就直接问 AI 吧。</div>
|
|
618
|
+
</div></div>
|
|
619
|
+
</div>
|
|
620
|
+
<div class="head-acts">
|
|
621
|
+
<span class="status" id="imageStatus"></span>
|
|
622
|
+
<button class="btn ghost mini" id="resetAll" type="button"
|
|
623
|
+
title="清掉选题、标题、正文、分镜清单、补充要求和参考图;图型 / 风格 / 主题色变成不限。画幅、情绪、留白、出图模式保留">一键清空</button>
|
|
624
|
+
<button class="btn" id="composeAll" type="button">生成提示词</button>
|
|
625
|
+
<button class="btn primary" id="genImage" type="button">批量出图</button>
|
|
626
|
+
<button class="btn stop" id="stopGen" type="button" title="停止当前正在出图与排队中的任务" disabled>停止生图</button>
|
|
627
|
+
<button class="btn ghost" id="randomCombo" type="button">随机组合</button>
|
|
628
|
+
<span class="ha-sep"></span>
|
|
629
|
+
<div class="dropdown" id="moreMenu">
|
|
630
|
+
<button class="btn ghost mini" id="moreToggle" type="button" aria-haspopup="true" aria-expanded="false">更多功能 <span class="caret">▾</span></button>
|
|
631
|
+
<div class="dropdown-list" id="moreList" hidden>
|
|
632
|
+
<button class="dd-item" id="openHistory" type="button">历史记录</button>
|
|
633
|
+
<button class="dd-item" id="openMonitor" type="button">运行监控</button>
|
|
634
|
+
<button class="dd-item" id="openConfig" type="button" title="出图接口、本地号池与账号导入">接口与号池</button>
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
<div class="pool-chip"><span class="dot" id="poolDot"></span><span id="poolText">号池连接中…</span></div>
|
|
638
|
+
</div>
|
|
639
|
+
</header>
|
|
640
|
+
|
|
641
|
+
<main>
|
|
642
|
+
<!-- 内容工坊 -->
|
|
643
|
+
<section class="panel left">
|
|
644
|
+
<h2>内容工坊 <span class="pill" id="modePill">小红书笔记</span>
|
|
645
|
+
<span class="spacer"></span>
|
|
646
|
+
<button class="btn ghost mini" id="saveDraft" type="button" title="内容会自动暂存;点这里是立刻存一次">暂存</button>
|
|
647
|
+
<button class="btn ghost mini" id="openDrafts" type="button">草稿 <em id="draftCount">0</em></button>
|
|
648
|
+
</h2>
|
|
649
|
+
<p class="hint save-hint"><span>文案不满意可让 AI 润色</span><span class="save-state" id="saveState" title="内容一改,1.5 秒没有新操作就自动存成草稿;切走页面也会立刻存一次。点这里可以马上保存"></span></p>
|
|
650
|
+
|
|
651
|
+
<label for="topic">选题 / 想写什么</label>
|
|
652
|
+
<textarea id="topic" rows="2" placeholder="例:秋天第一杯奶茶怎么点才不踩雷"></textarea>
|
|
653
|
+
<div class="row" style="margin-top:10px">
|
|
654
|
+
<button class="btn primary" id="genText">AI 生成文章</button>
|
|
655
|
+
<span class="status" id="textStatus"></span>
|
|
656
|
+
</div>
|
|
657
|
+
|
|
658
|
+
<label for="docTitle">标题</label>
|
|
659
|
+
<input id="docTitle" placeholder="标题会出现在这里">
|
|
660
|
+
|
|
661
|
+
<div class="section-title"><b>正文</b><span class="tag" id="textAccount"></span></div>
|
|
662
|
+
<textarea id="docBody" rows="14" placeholder="正文会出现在这里,可直接编辑"></textarea>
|
|
663
|
+
<button class="btn primary full" id="splitToItems" type="button">拆成分镜 →</button>
|
|
664
|
+
<div class="row" style="margin-top:8px">
|
|
665
|
+
<button class="btn ghost" id="polishText">AI 润色</button>
|
|
666
|
+
<button class="btn ghost" id="copyText">复制</button>
|
|
667
|
+
</div>
|
|
668
|
+
<div class="status" id="splitStatus"></div>
|
|
669
|
+
<div class="md" id="docRaw" style="display:none"></div>
|
|
670
|
+
</section>
|
|
671
|
+
|
|
672
|
+
<!-- 配图工坊 -->
|
|
673
|
+
<section class="panel" id="studioPanel">
|
|
674
|
+
<h2>配图工坊 <span class="pill">handraw-style</span></h2>
|
|
675
|
+
<p class="hint">先定组合拿到出图指令,再逐条补说明,程序拼好提示词后一次出图。提示词可手改。</p>
|
|
676
|
+
<div class="demo-note" id="demoNote" hidden>
|
|
677
|
+
<span id="demoNoteText"></span>
|
|
678
|
+
<button class="dn-x" id="demoNoteX" type="button" title="知道了">×</button>
|
|
679
|
+
</div>
|
|
680
|
+
|
|
681
|
+
<div class="pick-row">
|
|
682
|
+
<div class="pick-card" id="cardLayout">
|
|
683
|
+
<div class="pk-head">
|
|
684
|
+
<span class="pk-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18M10 10v10"/></svg></span>
|
|
685
|
+
<b>图型</b>
|
|
686
|
+
<span class="pk-opt" id="optLayout">可选 (170)</span>
|
|
687
|
+
</div>
|
|
688
|
+
<button class="pk-btn" id="emptyLayout" type="button">
|
|
689
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18M10 10v10"/></svg>
|
|
690
|
+
<span class="t">按图选择图型 <em id="cntLayout">(170)</em></span>
|
|
691
|
+
</button>
|
|
692
|
+
<div class="pk-body" id="bodyLayout" hidden>
|
|
693
|
+
<span class="pk-thumb"><img id="pimgLayout" alt="" loading="lazy"></span>
|
|
694
|
+
<span class="pk-meta">
|
|
695
|
+
<span class="pk-id" id="pidLayout">—</span>
|
|
696
|
+
<span class="pk-name" id="pnameLayout">—</span>
|
|
697
|
+
</span>
|
|
698
|
+
<span class="pk-acts">
|
|
699
|
+
<button class="pk-change" id="chgLayout" type="button">更换</button>
|
|
700
|
+
<button class="pk-clear" id="clrLayout" type="button" title="清除选择">×</button>
|
|
701
|
+
</span>
|
|
702
|
+
</div>
|
|
703
|
+
</div>
|
|
704
|
+
|
|
705
|
+
<div class="pick-card" id="cardStyle">
|
|
706
|
+
<div class="pk-head">
|
|
707
|
+
<span class="pk-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 100 18h1.4a2.6 2.6 0 000-5.2c-1.2 0-2-.8-2-1.9 0-1.1.9-1.9 2.1-1.9H18a3.5 3.5 0 003.5-3.5C21.5 5.6 17.2 3 12 3z"/><circle cx="7.8" cy="10.6" r="1"/><circle cx="12" cy="7.6" r="1"/><circle cx="16.4" cy="10.4" r="1"/></svg></span>
|
|
708
|
+
<b>风格</b>
|
|
709
|
+
<span class="pk-opt" id="optStyle">可选 (367)</span>
|
|
710
|
+
</div>
|
|
711
|
+
<button class="pk-btn" id="emptyStyle" type="button">
|
|
712
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 100 18h1.4a2.6 2.6 0 000-5.2c-1.2 0-2-.8-2-1.9 0-1.1.9-1.9 2.1-1.9H18a3.5 3.5 0 003.5-3.5C21.5 5.6 17.2 3 12 3z"/><circle cx="7.8" cy="10.6" r="1"/><circle cx="12" cy="7.6" r="1"/><circle cx="16.4" cy="10.4" r="1"/></svg>
|
|
713
|
+
<span class="t">按图选择风格 <em id="cntStyle">(367)</em></span>
|
|
714
|
+
</button>
|
|
715
|
+
<div class="pk-body" id="bodyStyle" hidden>
|
|
716
|
+
<span class="pk-thumb"><img id="pimgStyle" alt="" loading="lazy"></span>
|
|
717
|
+
<span class="pk-meta">
|
|
718
|
+
<span class="pk-id" id="pidStyle">—</span>
|
|
719
|
+
<span class="pk-name" id="pnameStyle">—</span>
|
|
720
|
+
</span>
|
|
721
|
+
<span class="pk-acts">
|
|
722
|
+
<button class="pk-change" id="chgStyle" type="button">更换</button>
|
|
723
|
+
<button class="pk-clear" id="clrStyle" type="button" title="清除选择">×</button>
|
|
724
|
+
</span>
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
|
|
728
|
+
<div class="pick-card" id="cardColor">
|
|
729
|
+
<div class="pk-head">
|
|
730
|
+
<span class="pk-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5s6 6.4 6 10.2a6 6 0 11-12 0c0-3.8 6-10.2 6-10.2z"/></svg></span>
|
|
731
|
+
<b>主题色</b>
|
|
732
|
+
<span class="pk-opt" id="optColor">可选 (36)</span>
|
|
733
|
+
</div>
|
|
734
|
+
<button class="pk-btn" id="emptyColor" type="button">
|
|
735
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5s6 6.4 6 10.2a6 6 0 11-12 0c0-3.8 6-10.2 6-10.2z"/></svg>
|
|
736
|
+
<span class="t">按图选择主题色 <em id="cntColor">(36)</em></span>
|
|
737
|
+
</button>
|
|
738
|
+
<div class="pk-body" id="bodyColor" hidden>
|
|
739
|
+
<span class="pk-thumb"><img id="pimgColor" alt="" loading="lazy"></span>
|
|
740
|
+
<span class="pk-meta">
|
|
741
|
+
<span class="pk-id" id="pidColor">—</span>
|
|
742
|
+
<span class="pk-name" id="pnameColor">—</span>
|
|
743
|
+
</span>
|
|
744
|
+
<span class="pk-acts">
|
|
745
|
+
<button class="pk-change" id="chgColor" type="button">更换</button>
|
|
746
|
+
<button class="pk-clear" id="clrColor" type="button" title="清除选择">×</button>
|
|
747
|
+
</span>
|
|
748
|
+
</div>
|
|
749
|
+
</div>
|
|
750
|
+
|
|
751
|
+
<div class="pick-card" id="cardRef">
|
|
752
|
+
<div class="pk-head">
|
|
753
|
+
<span class="pk-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.8" cy="9" r="1.6"/><path d="M21 15.5l-4.2-4.2L7 21"/></svg></span>
|
|
754
|
+
<b>参考图</b>
|
|
755
|
+
<span class="pk-opt">可选</span>
|
|
756
|
+
</div>
|
|
757
|
+
<button class="pk-btn" id="emptyRef" type="button">
|
|
758
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4m0 0L8 8m4-4l4 4"/><path d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg>
|
|
759
|
+
<span class="t">上传 / 拖入参考图</span>
|
|
760
|
+
</button>
|
|
761
|
+
<div class="pk-body" id="bodyRef" hidden>
|
|
762
|
+
<span class="pk-thumb"><img id="pimgRef" alt="参考图" loading="lazy"></span>
|
|
763
|
+
<span class="pk-meta">
|
|
764
|
+
<span class="pk-id" id="pidRef">参考图</span>
|
|
765
|
+
<span class="pk-name" id="pnameRef">—</span>
|
|
766
|
+
</span>
|
|
767
|
+
<span class="pk-acts">
|
|
768
|
+
<button class="pk-change" id="chgRef" type="button">更换</button>
|
|
769
|
+
<button class="pk-clear" id="clrRef" type="button" title="移除参考图">×</button>
|
|
770
|
+
</span>
|
|
771
|
+
</div>
|
|
772
|
+
<input type="file" id="refFile" accept="image/*" hidden>
|
|
773
|
+
</div>
|
|
774
|
+
</div>
|
|
775
|
+
|
|
776
|
+
<!-- 出图模式:图文=图里排标题正文(原样不动);摄影=按拍摄类型出写真,图里不排文字 -->
|
|
777
|
+
<div class="params" id="omodeRow">
|
|
778
|
+
<span class="plabel">出图模式</span>
|
|
779
|
+
<div class="seg" id="omodeSeg">
|
|
780
|
+
<button type="button" data-omode="graphic-text" class="on">图文</button>
|
|
781
|
+
<button type="button" data-omode="photo">摄影</button>
|
|
782
|
+
</div>
|
|
783
|
+
<span class="hint" id="omodeHint" style="margin:0">图里排标题和正文文字</span>
|
|
784
|
+
</div>
|
|
785
|
+
<div id="shootWrap" hidden>
|
|
786
|
+
<div class="params" style="margin-top:8px">
|
|
787
|
+
<span class="plabel">拍摄类型</span>
|
|
788
|
+
<input id="shootType" placeholder="点下面的标签,或手写(如:情侣)" style="flex:1;min-width:160px;padding:6px 10px">
|
|
789
|
+
</div>
|
|
790
|
+
<div class="chips" id="shootChips"></div>
|
|
791
|
+
</div>
|
|
792
|
+
|
|
793
|
+
<div class="params">
|
|
794
|
+
<span class="plabel">画幅</span>
|
|
795
|
+
<select id="ratio" style="width:136px;padding:6px 9px">
|
|
796
|
+
<option value="3:4">3:4 竖版</option>
|
|
797
|
+
<option value="1:1">1:1 方图</option>
|
|
798
|
+
<option value="4:3">4:3 横版</option>
|
|
799
|
+
<option value="16:9">16:9 封面</option>
|
|
800
|
+
<option value="2.35:1">2.35:1 公众号封面</option>
|
|
801
|
+
<option value="9:16">9:16 全屏</option>
|
|
802
|
+
</select>
|
|
803
|
+
<span class="plabel">情绪</span>
|
|
804
|
+
<input id="mood" value="治愈、松弛、幽默" placeholder="治愈、幽默" style="flex:1;min-width:110px;padding:6px 10px">
|
|
805
|
+
<div class="seg" id="wsSeg">
|
|
806
|
+
<button type="button" data-ws="normal" class="on">留白正常</button>
|
|
807
|
+
<button type="button" data-ws="moderate">大量留白</button>
|
|
808
|
+
<button type="button" data-ws="high">极简</button>
|
|
809
|
+
</div>
|
|
810
|
+
</div>
|
|
811
|
+
<div class="chips" id="moodChips"></div>
|
|
812
|
+
<label class="cbx" style="margin-top:9px" id="fusionRow">
|
|
813
|
+
<input type="checkbox" id="fusion" checked>
|
|
814
|
+
<span>缩微摄影融合 · 角色小、场景真,同光源同空间(出图更有故事感)</span>
|
|
815
|
+
</label>
|
|
816
|
+
|
|
817
|
+
<div class="stepbar">
|
|
818
|
+
<span class="step-no">1</span><b>出图指令</b>
|
|
819
|
+
<span class="hint" id="instrHint" style="margin:0">组合一变就更新,可直接手改</span>
|
|
820
|
+
<span class="spacer"></span>
|
|
821
|
+
<button class="btn ghost mini" id="resetInstr" type="button" hidden>还原</button>
|
|
822
|
+
<button class="btn ghost mini" id="copyInstr" type="button">复制</button>
|
|
823
|
+
</div>
|
|
824
|
+
<div class="instr" id="instrOut" contenteditable="true" spellcheck="false" data-ph="图型 / 风格 / 主题色都可以不选,这里会写出这一轮靠什么出图,也能直接手改">图型 / 风格 / 主题色都可以不选,这里会写出这一轮靠什么出图</div>
|
|
825
|
+
|
|
826
|
+
<div class="stepbar">
|
|
827
|
+
<span class="step-no">2</span><b>分镜清单</b>
|
|
828
|
+
<span class="tag" id="itemCount">0 条</span>
|
|
829
|
+
<span class="tag" id="groupTag"></span>
|
|
830
|
+
<span class="spacer"></span>
|
|
831
|
+
<button class="btn ghost mini" id="copyAll" type="button">复制全部</button>
|
|
832
|
+
<button class="btn ghost mini" id="importItems" type="button">粘贴清单</button>
|
|
833
|
+
<button class="btn ghost mini" id="importPromptOuter" type="button" title="粘贴现成提示词,一段一条,标题正文留空直接生图">导入提示词</button>
|
|
834
|
+
<button class="btn ghost mini" id="addItem" type="button">+ 加一条</button>
|
|
835
|
+
</div>
|
|
836
|
+
<div class="redo-bar" id="redoBar" hidden>
|
|
837
|
+
<span id="redoText"></span>
|
|
838
|
+
<span class="spacer"></span>
|
|
839
|
+
<button class="btn ghost mini" id="redoExit" type="button">退出重做</button>
|
|
840
|
+
</div>
|
|
841
|
+
<div class="items" id="itemList"></div>
|
|
842
|
+
<div class="empty-items" id="itemEmpty">把整份清单直接粘到这块区域,会自动识别成 N 张卡片,N 张就出 N 张图。<br>格式:<b>01|先存后花</b> 换行写正文,下一条接着写 <b>02|拿铁因子</b>。也可以点「+ 加一条」手写。</div>
|
|
843
|
+
|
|
844
|
+
<label for="note" style="margin-top:12px">通用补充要求(可选,每条都生效)</label>
|
|
845
|
+
<textarea id="note" rows="2" placeholder="例:不要出现多余小字,标题留白多一点"></textarea>
|
|
846
|
+
</section>
|
|
847
|
+
|
|
848
|
+
<!-- 排版预览 -->
|
|
849
|
+
<aside class="panel right">
|
|
850
|
+
<h2>排版预览</h2>
|
|
851
|
+
<p class="hint">排版预览,实时查看发布之后移动端排版效果。</p>
|
|
852
|
+
<div class="phone">
|
|
853
|
+
<div class="bar" id="phoneBar">小红书 · 预览</div>
|
|
854
|
+
<div class="body">
|
|
855
|
+
<div class="cover-wrap">
|
|
856
|
+
<div class="cover" id="cover">等待生成配图</div>
|
|
857
|
+
</div>
|
|
858
|
+
<div class="cover-nav-row" id="coverNavRow">
|
|
859
|
+
<button class="cover-nav" id="coverPrev" type="button" title="上一张" hidden>‹</button>
|
|
860
|
+
<span id="coverCount"></span>
|
|
861
|
+
<button class="cover-nav" id="coverNext" type="button" title="下一张" hidden>›</button>
|
|
862
|
+
</div>
|
|
863
|
+
<h4 id="pTitle">标题会同步到这里</h4>
|
|
864
|
+
<p id="pBody">正文会同步到这里。</p>
|
|
865
|
+
<div class="tags" id="pTags"></div>
|
|
866
|
+
</div>
|
|
867
|
+
</div>
|
|
868
|
+
<div class="status" id="ratioNote" hidden></div>
|
|
869
|
+
<div class="status" id="viewerMeta">预览显示压缩图 · 单击看原图 · 滚轮放大</div>
|
|
870
|
+
|
|
871
|
+
<div class="status" id="poolDetail"></div>
|
|
872
|
+
<div class="dl-row" id="dlRow">
|
|
873
|
+
<a class="btn ghost" id="downloadBtn" href="#" download style="text-align:center;text-decoration:none">下载当前图片</a>
|
|
874
|
+
<button class="btn ghost" id="downloadAll" type="button" title="把本轮所有生成成功的图片按展示顺序打成一个 zip">下载全部图片</button>
|
|
875
|
+
</div>
|
|
876
|
+
<div class="footer-note">
|
|
877
|
+
号池 = 10 个 ChatGPT 账号(独立指纹环境)。多任务并行(按账号数自动排队),单账号 90 秒冷却,登录失效自动下线。
|
|
878
|
+
</div>
|
|
879
|
+
</aside>
|
|
880
|
+
</main>
|
|
881
|
+
|
|
882
|
+
<dialog id="importDlg">
|
|
883
|
+
<div class="inner">
|
|
884
|
+
<h3 id="importTitle">批量导入分镜</h3>
|
|
885
|
+
<p class="hint" id="importHint" style="margin:2px 0 8px">整段贴进来即可。每条以 <b>01|先存后花</b> 开头,下一行写正文,遇到下一个编号自动断开。识别出几张就生成几张图。<br>贴的是现成提示词?点工具条的「<b>导入提示词</b>」。</p>
|
|
886
|
+
<textarea id="importText" rows="12" placeholder="01|先存后花 工资到账的第一件事,不是看余额,是先转走一笔到不绑任何支付工具的账户。 02|拿铁因子 每天一杯奶茶、一个会员、一次顺手的小额支付,一年能悄悄流走五位数。"></textarea>
|
|
887
|
+
<div class="row" style="margin-top:10px">
|
|
888
|
+
<span class="hint" id="importCount" style="margin:0">粘贴后自动识别</span>
|
|
889
|
+
<span class="spacer"></span>
|
|
890
|
+
<button class="btn ghost" id="importCancel" type="button">取消</button>
|
|
891
|
+
<button class="btn primary" id="importOk" type="button">导入</button>
|
|
892
|
+
</div>
|
|
893
|
+
</div>
|
|
894
|
+
</dialog>
|
|
895
|
+
|
|
896
|
+
<dialog id="pickerDlg" class="picker">
|
|
897
|
+
<div class="inner">
|
|
898
|
+
<div class="dlg-head">
|
|
899
|
+
<h3 id="pickerTitle">全部风格</h3>
|
|
900
|
+
<span class="spacer"></span>
|
|
901
|
+
<button class="btn ghost mini" id="pickerClose" type="button">关闭</button>
|
|
902
|
+
</div>
|
|
903
|
+
<div class="chips" id="pickerChips"></div>
|
|
904
|
+
<input id="pickerFilter" placeholder="搜索编号 / 名称" style="font-size:12px;padding:7px 10px;margin-bottom:8px">
|
|
905
|
+
<div class="scroller" style="max-height:74vh"><div class="grid-styles" id="pickerGrid"></div></div>
|
|
906
|
+
</div>
|
|
907
|
+
</dialog>
|
|
908
|
+
|
|
909
|
+
<dialog id="viewDlg" class="viewer">
|
|
910
|
+
<div class="viewer-stage">
|
|
911
|
+
<button class="viewer-nav prev" id="viewPrev" type="button" aria-label="上一张" title="上一张(←)">
|
|
912
|
+
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M15 4.5 7.5 12 15 19.5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
913
|
+
</button>
|
|
914
|
+
<img id="viewImg" alt="大图预览">
|
|
915
|
+
<button class="viewer-nav next" id="viewNext" type="button" aria-label="下一张" title="下一张(→)">
|
|
916
|
+
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M9 4.5 16.5 12 9 19.5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
917
|
+
</button>
|
|
918
|
+
</div>
|
|
919
|
+
<div class="viewer-cap">
|
|
920
|
+
<span id="viewCap"></span>
|
|
921
|
+
<span id="viewZoom" title="滚轮放大 / 缩小;放大后可按住拖动查看"></span>
|
|
922
|
+
<a class="dl" id="viewDl" href="#" download hidden>下载图片</a>
|
|
923
|
+
<button id="viewClose">关闭 (Esc)</button>
|
|
924
|
+
</div>
|
|
925
|
+
</dialog>
|
|
926
|
+
|
|
927
|
+
<dialog id="historyDlg" class="wide">
|
|
928
|
+
<div class="inner">
|
|
929
|
+
<div class="dlg-head">
|
|
930
|
+
<h3>生成历史 <span id="historyCount">0 个任务</span></h3>
|
|
931
|
+
<span class="spacer"></span>
|
|
932
|
+
<input id="historyFilter" placeholder="搜任务标题 / 分镜标题">
|
|
933
|
+
<select id="historyPageSize" title="每页显示多少个任务">
|
|
934
|
+
<option value="20" selected>每页 20</option>
|
|
935
|
+
<option value="50">每页 50</option>
|
|
936
|
+
<option value="100">每页 100</option>
|
|
937
|
+
</select>
|
|
938
|
+
<button class="btn ghost mini" id="historyRefresh" type="button">刷新</button>
|
|
939
|
+
<button class="btn ghost mini" id="historyClose" type="button">关闭</button>
|
|
940
|
+
</div>
|
|
941
|
+
<div class="task-list" id="taskList"></div>
|
|
942
|
+
<div class="hist-empty" id="historyEmpty" hidden>还没有生成记录。出图成功后会自动记在这里,可随时回看和下载。</div>
|
|
943
|
+
<div class="pager">
|
|
944
|
+
<span id="taskPageInfo">—</span>
|
|
945
|
+
<span class="spacer"></span>
|
|
946
|
+
<button id="taskPrev" type="button" disabled>上一页</button>
|
|
947
|
+
<button id="taskNext" type="button" disabled>下一页</button>
|
|
948
|
+
</div>
|
|
949
|
+
</div>
|
|
950
|
+
</dialog>
|
|
951
|
+
|
|
952
|
+
<dialog id="draftsDlg">
|
|
953
|
+
<div class="inner">
|
|
954
|
+
<div class="dlg-head">
|
|
955
|
+
<h3>草稿箱 <span id="draftHint">暂存的内容存在服务器,换设备也能接着改 · 点重命名可自定义名称</span></h3>
|
|
956
|
+
<span class="spacer"></span>
|
|
957
|
+
<button class="btn ghost mini" id="draftNew" type="button">另存当前为新草稿</button>
|
|
958
|
+
<button class="btn ghost mini" id="draftsClose" type="button">关闭</button>
|
|
959
|
+
</div>
|
|
960
|
+
<div class="draft-list" id="draftList"></div>
|
|
961
|
+
<div class="hist-empty" id="draftEmpty" hidden>还没有暂存。左边写完点什么,点「暂存」就会出现在这里。</div>
|
|
962
|
+
</div>
|
|
963
|
+
</dialog>
|
|
964
|
+
|
|
965
|
+
<dialog id="monitorDlg" class="wide">
|
|
966
|
+
<div class="inner">
|
|
967
|
+
<div class="dlg-head">
|
|
968
|
+
<h3>运行监控 <span id="monUpdated"></span></h3>
|
|
969
|
+
<span class="spacer"></span>
|
|
970
|
+
<button class="btn stop mini" id="monStop" type="button" title="停止当前生图与排队中的任务" disabled>停止当前任务</button>
|
|
971
|
+
<button class="btn ghost mini" id="monRefresh" type="button">刷新绑定</button>
|
|
972
|
+
<button class="btn ghost mini" id="monReset" type="button">重置状态</button>
|
|
973
|
+
<button class="btn mini" id="monCheck" type="button">检测全部账号</button>
|
|
974
|
+
<button class="btn ghost mini" id="monClose" type="button">关闭</button>
|
|
975
|
+
</div>
|
|
976
|
+
<div class="mon-sum" id="monSum"></div>
|
|
977
|
+
<div class="mon-sec"><b>账号状态</b><span id="monAccHint"></span><span class="spacer"></span><span id="monCheckHint"></span></div>
|
|
978
|
+
<div class="mon-box">
|
|
979
|
+
<table class="mon">
|
|
980
|
+
<thead><tr><th>账号</th><th>环境</th><th>状态</th><th>成功</th><th>失败</th><th>冷却</th><th>最近成功</th><th title="号池里这个号还剩多少次出图额度;有异常时接在后面">配额 / 备注</th></tr></thead>
|
|
981
|
+
<tbody id="monAccBody"></tbody>
|
|
982
|
+
</table>
|
|
983
|
+
</div>
|
|
984
|
+
<div class="mon-sec"><b>进行中 / 排队</b><span id="monJobHint"></span></div>
|
|
985
|
+
<div class="mon-jobs" id="monJobs"></div>
|
|
986
|
+
<div class="mon-sec"><b>今日失败账号</b><span id="monFailHint"></span></div>
|
|
987
|
+
<div class="mon-jobs" id="monFails"></div>
|
|
988
|
+
<div class="mon-sec"><b>最近失败</b></div>
|
|
989
|
+
<div class="mon-jobs" id="monErrors"></div>
|
|
990
|
+
</div>
|
|
991
|
+
</dialog>
|
|
992
|
+
|
|
993
|
+
<dialog id="configDlg">
|
|
994
|
+
<div class="inner">
|
|
995
|
+
<div class="dlg-head">
|
|
996
|
+
<h3>接口与号池 <span id="cfgEngine" style="font-weight:400;color:var(--ink-3);font-size:12px"></span><span id="cfgVersion" style="font-weight:400;color:var(--ink-3);font-size:11px;opacity:.7;margin-left:6px"></span></h3>
|
|
997
|
+
<span class="spacer"></span>
|
|
998
|
+
<button class="btn ghost mini" id="cfgClose" type="button">关闭</button>
|
|
999
|
+
</div>
|
|
1000
|
+
<div class="cfg-tabs" role="tablist">
|
|
1001
|
+
<button class="cfg-tab on" type="button" data-tab="iface" role="tab">出图接口</button>
|
|
1002
|
+
<button class="cfg-tab" type="button" data-tab="pool" role="tab">本地号池<i id="poolTabTag"></i></button>
|
|
1003
|
+
</div>
|
|
1004
|
+
|
|
1005
|
+
<div id="tabIface">
|
|
1006
|
+
<p style="font-size:12px;color:var(--ink-3);margin:0 0 12px;line-height:1.6">
|
|
1007
|
+
填任意 OpenAI 兼容接口即可出图。默认连本机生图号池;换第三方接口只要改 Base URL 与 API Key。API Key 留空表示沿用已保存的密钥,不会清掉。
|
|
1008
|
+
</p>
|
|
1009
|
+
<div class="iface-grid">
|
|
1010
|
+
<label style="display:block;font-size:12px;color:var(--ink-3)">工作台名称
|
|
1011
|
+
<input id="cfgAppName" maxlength="40" style="width:100%;margin-top:4px;padding:8px;border:1px solid #e4dff2;border-radius:8px;font-size:13px" placeholder="例:Bluxe手绘工坊 · 生图工作台">
|
|
1012
|
+
<span id="cfgAppNameHint" style="font-size:11px;color:var(--ink-3)">改完保存,页头标题和浏览器标签一起变;留空回到默认名。</span>
|
|
1013
|
+
</label>
|
|
1014
|
+
<label style="display:block;font-size:12px;color:var(--ink-3)">接口地址 Base URL
|
|
1015
|
+
<input id="cfgBase" style="width:100%;margin-top:4px;padding:8px;border:1px solid #e4dff2;border-radius:8px;font-size:13px" placeholder="http://127.0.0.1:38651/v1">
|
|
1016
|
+
<span style="font-size:11px;color:var(--ink-3)">填到根就行,漏写 /v1 会自动补上</span>
|
|
1017
|
+
</label>
|
|
1018
|
+
<label style="display:block;font-size:12px;color:var(--ink-3)">API Key
|
|
1019
|
+
<input id="cfgKey" type="password" autocomplete="off" style="width:100%;margin-top:4px;padding:8px;border:1px solid #e4dff2;border-radius:8px;font-size:13px" placeholder="sk-...">
|
|
1020
|
+
<span id="cfgKeyHint" style="font-size:11px;color:var(--ink-3)"></span>
|
|
1021
|
+
</label>
|
|
1022
|
+
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
|
1023
|
+
<label style="display:block;font-size:12px;color:var(--ink-3);flex:1;min-width:150px">出图模型
|
|
1024
|
+
<input id="cfgModelImage" style="width:100%;margin-top:4px;padding:8px;border:1px solid #e4dff2;border-radius:8px;font-size:13px" placeholder="gpt-image-2 / dall-e-3">
|
|
1025
|
+
</label>
|
|
1026
|
+
<label style="display:block;font-size:12px;color:var(--ink-3);flex:1;min-width:150px">文案模型
|
|
1027
|
+
<input id="cfgModelText" style="width:100%;margin-top:4px;padding:8px;border:1px solid #e4dff2;border-radius:8px;font-size:13px" placeholder="auto 或 gpt-4o-mini">
|
|
1028
|
+
</label>
|
|
1029
|
+
</div>
|
|
1030
|
+
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ink-3);white-space:nowrap">
|
|
1031
|
+
<input id="cfgEdits" type="checkbox" style="width:auto;flex:0 0 auto;margin:0">
|
|
1032
|
+
<span>支持参考图(edits,/v1/images/edits)</span>
|
|
1033
|
+
</div>
|
|
1034
|
+
<div style="display:flex;gap:18px;align-items:center;font-size:12px;color:var(--ink-3);flex-wrap:wrap">
|
|
1035
|
+
<div style="display:flex;align-items:center;gap:6px;white-space:nowrap">返回格式
|
|
1036
|
+
<select id="cfgFormat" style="padding:4px;border:1px solid #e4dff2;border-radius:6px"><option value="auto">自动</option><option value="b64_json">b64_json</option><option value="url">url</option></select>
|
|
1037
|
+
</div>
|
|
1038
|
+
<div style="display:flex;align-items:center;gap:6px;white-space:nowrap">参考图方式
|
|
1039
|
+
<select id="cfgEditsStyle" style="padding:4px;border:1px solid #e4dff2;border-radius:6px"><option value="auto">自动</option><option value="json">JSON(号池)</option><option value="multipart">multipart(标准)</option><option value="off">关闭</option></select>
|
|
1040
|
+
</div>
|
|
1041
|
+
</div>
|
|
1042
|
+
<div class="span2" style="border-top:1px solid #f0ecfa;padding-top:10px">
|
|
1043
|
+
<div style="font-size:12px;color:var(--ink-3);margin-bottom:4px">生成图片保存目录</div>
|
|
1044
|
+
<div style="display:flex;gap:8px;align-items:center">
|
|
1045
|
+
<input id="cfgOutDir" style="flex:1;padding:8px;border:1px solid #e4dff2;border-radius:8px;font-size:13px" placeholder="留空 = 工作台默认目录">
|
|
1046
|
+
<button class="btn ghost mini" id="cfgOpenDir" type="button" style="flex:0 0 auto;white-space:nowrap">打开目录</button>
|
|
1047
|
+
</div>
|
|
1048
|
+
<span id="cfgOutHint" style="font-size:11px;color:var(--ink-3);line-height:1.6"></span>
|
|
1049
|
+
</div>
|
|
1050
|
+
</div>
|
|
1051
|
+
</div>
|
|
1052
|
+
|
|
1053
|
+
<div id="tabPool" hidden>
|
|
1054
|
+
<div class="pool-col pool-left">
|
|
1055
|
+
<div class="pstate">
|
|
1056
|
+
<span class="dot" id="pDot"></span>
|
|
1057
|
+
<b id="pState">读取中…</b>
|
|
1058
|
+
<span class="pmeta" id="pMeta"></span>
|
|
1059
|
+
<span class="spacer"></span>
|
|
1060
|
+
<button class="btn primary mini" id="pStart" type="button" hidden>启动号池</button>
|
|
1061
|
+
<button class="btn ghost mini" id="pRestart" type="button" hidden>重启</button>
|
|
1062
|
+
<button class="btn ghost mini" id="pStop" type="button" hidden>停止</button>
|
|
1063
|
+
<button class="btn ghost mini" id="pReload" type="button">刷新</button>
|
|
1064
|
+
<button class="btn primary mini" id="pUseBuiltin" type="button" hidden
|
|
1065
|
+
title="把出图接口改回本机内置号池 38651,并对齐密钥、启动它。外部号池的账号与进程不受影响">↩ 切回内置池</button>
|
|
1066
|
+
</div>
|
|
1067
|
+
<div class="prow">
|
|
1068
|
+
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--ink-3);white-space:nowrap">
|
|
1069
|
+
<input id="pAuto" type="checkbox" style="width:auto;margin:0"> 掉线自动拉起
|
|
1070
|
+
</label>
|
|
1071
|
+
<span class="pmeta" id="pSvcMsg"></span>
|
|
1072
|
+
</div>
|
|
1073
|
+
<div id="pWarn" class="pwarn" hidden></div>
|
|
1074
|
+
<div class="pbox">
|
|
1075
|
+
<div class="prow">
|
|
1076
|
+
<span class="plabel">接口地址</span>
|
|
1077
|
+
<code id="pBase">—</code>
|
|
1078
|
+
<button class="btn ghost mini" type="button" data-copy="pBase">复制</button>
|
|
1079
|
+
</div>
|
|
1080
|
+
<div class="prow">
|
|
1081
|
+
<span class="plabel">API Key</span>
|
|
1082
|
+
<code id="pKey">—</code>
|
|
1083
|
+
<button class="btn ghost mini" type="button" data-copy="pKey">复制</button>
|
|
1084
|
+
<button class="btn ghost mini" type="button" id="pKeyNew">重新生成</button>
|
|
1085
|
+
</div>
|
|
1086
|
+
<div class="prow" style="margin-top:4px">
|
|
1087
|
+
<button class="btn primary mini" type="button" id="pApply">用这个号池出图</button>
|
|
1088
|
+
</div>
|
|
1089
|
+
<p class="phint" id="pHome"></p>
|
|
1090
|
+
</div>
|
|
1091
|
+
<div class="psec"><b>号池出口代理</b><span class="tag" id="pProxyTag"></span></div>
|
|
1092
|
+
<div class="ptip"><span>🌐</span><span>号池要连 OpenAI / ChatGPT,生图必须走代理。这里显示本机当前在用的地址,换机器会自动跟着变。</span></div>
|
|
1093
|
+
<div class="prow" style="align-items:flex-start;gap:8px">
|
|
1094
|
+
<input id="pProxy" type="text" placeholder="留空=直连" style="flex:1 1 auto;min-width:0;width:100%">
|
|
1095
|
+
<button class="btn ghost mini" type="button" id="pProxyUse" hidden>用本机代理</button>
|
|
1096
|
+
<button class="btn ghost mini" type="button" id="pProxyTest">测试</button>
|
|
1097
|
+
<button class="btn primary mini" type="button" id="pProxySave">保存代理</button>
|
|
1098
|
+
</div>
|
|
1099
|
+
<div class="prow"><span class="pmeta" id="pProxyLocal"></span></div>
|
|
1100
|
+
<div class="prow"><span class="pmeta" id="pProxyMsg"></span></div>
|
|
1101
|
+
<div class="psec"><b>导入账号</b></div>
|
|
1102
|
+
<div class="ptip"><span>💡</span><span>提示:可以上 <a href="https://priceai.cc/" target="_blank" rel="noopener noreferrer">https://priceai.cc/</a> 买几分钱的普号(一般 3-5 毛钱可以买 10 个)。购买后直接上传或粘贴在下方文本框,点击「导入并授权入池」即可。</span></div>
|
|
1103
|
+
<div class="pdrop" id="pDropZone" tabindex="0" title="支持 txt / json / csv / tsv / log,可多选,单个文件不超过 5 MB">
|
|
1104
|
+
<input id="pFile" type="file" multiple hidden accept=".txt,.json,.csv,.tsv,.log,text/plain,application/json">
|
|
1105
|
+
<span class="pdrop-ico">⬆</span>
|
|
1106
|
+
<span class="pdrop-hint">点击选择文件,或把文件拖到这里</span>
|
|
1107
|
+
<span class="pmeta" id="pFileName">未选择文件</span>
|
|
1108
|
+
<button class="btn ghost mini" type="button" id="pPick">选择文件</button>
|
|
1109
|
+
<button class="btn ghost mini" type="button" id="pClear">清空</button>
|
|
1110
|
+
</div>
|
|
1111
|
+
<textarea id="pImport" rows="4" placeholder="每行一个 access_token,或粘贴账号 JSON / 五段式发料 txt 支持 txt / json / csv,可直接拖文件进来(邮箱密码会一起入池)"></textarea>
|
|
1112
|
+
<div class="prow">
|
|
1113
|
+
<button class="btn primary" type="button" id="pImportBtn">导入并授权入池</button>
|
|
1114
|
+
<span class="pmeta" id="pImportMsg"></span>
|
|
1115
|
+
</div>
|
|
1116
|
+
</div>
|
|
1117
|
+
<div class="pool-col pool-right">
|
|
1118
|
+
<div class="psec"><b>账号</b><span class="tag" id="pAccTag"></span></div>
|
|
1119
|
+
<div id="pAccList" class="pacc"></div>
|
|
1120
|
+
</div>
|
|
1121
|
+
</div>
|
|
1122
|
+
|
|
1123
|
+
<div class="cfg-foot" id="cfgFoot">
|
|
1124
|
+
<span id="cfgResult" style="font-size:12px;min-height:18px;line-height:1.5"></span>
|
|
1125
|
+
<span class="spacer"></span>
|
|
1126
|
+
<button class="btn ghost" id="cfgTest" type="button">测试连接</button>
|
|
1127
|
+
<button class="btn primary" id="cfgSave" type="button">保存</button>
|
|
1128
|
+
</div>
|
|
1129
|
+
</div>
|
|
1130
|
+
</dialog>
|
|
1131
|
+
|
|
1132
|
+
<div class="toast" id="toast" role="status" aria-live="polite">
|
|
1133
|
+
<div class="t-tx"><b id="toastTitle" hidden></b><span id="toastMsg"></span></div>
|
|
1134
|
+
</div>
|
|
1135
|
+
|
|
1136
|
+
<script>
|
|
1137
|
+
const $ = s => document.querySelector(s);
|
|
1138
|
+
const state = {styles:[], layouts:[], colors:[], sel:{style:null, layout:null, color:null},
|
|
1139
|
+
picker:{kind:'style', group:'全部'},
|
|
1140
|
+
results:[], idx:0, instrCustom:null, redo:null, selTouched:false};
|
|
1141
|
+
|
|
1142
|
+
/* 气泡:默认是轻提示;出图完成走 ok(带勾,停 5 秒),失败走 err。
|
|
1143
|
+
页面在后台时不硬弹(反正看不见),先攒一条,切回来再补。 */
|
|
1144
|
+
const TOAST_MS = {ok: 5200, err: 5600, info: 3200};
|
|
1145
|
+
let _bgNotice = null;
|
|
1146
|
+
function toast(msg, opt){
|
|
1147
|
+
const o = (typeof opt === 'string') ? {type: opt} : (opt || {});
|
|
1148
|
+
if (document.hidden && !o.force){ _bgNotice = {msg, opt: o}; return; }
|
|
1149
|
+
const t = $('#toast');
|
|
1150
|
+
const title = $('#toastTitle');
|
|
1151
|
+
title.textContent = o.title || '';
|
|
1152
|
+
title.hidden = !o.title;
|
|
1153
|
+
$('#toastMsg').textContent = msg || '';
|
|
1154
|
+
t.className = 'toast' + (o.type ? ' ' + o.type : '');
|
|
1155
|
+
void t.offsetWidth; // 连着来两条也要重新走一遍入场动画
|
|
1156
|
+
t.classList.add('show');
|
|
1157
|
+
clearTimeout(t._h);
|
|
1158
|
+
t._h = setTimeout(()=> t.classList.remove('show'), o.ms || TOAST_MS[o.type] || 3200);
|
|
1159
|
+
t.onclick = ()=>{ clearTimeout(t._h); t.classList.remove('show'); if (o.click) o.click(); };
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/* 用时:38 秒 / 4 分 12 秒 */
|
|
1163
|
+
function fmtDur(ms){
|
|
1164
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
1165
|
+
if (s < 60) return s + ' 秒';
|
|
1166
|
+
const m = Math.floor(s / 60);
|
|
1167
|
+
return m + ' 分 ' + String(s % 60).padStart(2, '0') + ' 秒';
|
|
1168
|
+
}
|
|
1169
|
+
// 样图资源版本:补真图 / 换图源 / 改占位逻辑时 +1,用来顶掉浏览器缓存的旧渐变占位。
|
|
1170
|
+
// 占位 SVG 之前被缓存了 1 天(max-age=86400),导致补了真图后画廊仍显示渐变,加版本号可自愈。
|
|
1171
|
+
const IMG_VER = '2';
|
|
1172
|
+
function imgUrl(p){ return '/img/' + String(p||'').replace(/^images\//,'') + '?v=' + IMG_VER; }
|
|
1173
|
+
|
|
1174
|
+
async function loadCatalog(){
|
|
1175
|
+
const r = await fetch('/api/catalog'); const d = await r.json();
|
|
1176
|
+
state.styles = d.styles; state.layouts = d.layouts; state.colors = d.colors;
|
|
1177
|
+
$('#optLayout').textContent = `可选 (${state.layouts.length})`;
|
|
1178
|
+
$('#optStyle').textContent = `可选 (${state.styles.length})`;
|
|
1179
|
+
$('#optColor').textContent = `可选 (${state.colors.length})`;
|
|
1180
|
+
$('#cntLayout').textContent = `(${state.layouts.length})`;
|
|
1181
|
+
$('#cntStyle').textContent = `(${state.styles.length})`;
|
|
1182
|
+
$('#cntColor').textContent = `(${state.colors.length})`;
|
|
1183
|
+
// 默认选择一组有辨识度的组合
|
|
1184
|
+
state.sel.style = state.styles.find(s=>s.id==='FA-018') || state.styles[0];
|
|
1185
|
+
state.sel.color = state.colors.find(c=>c.id==='C-26') || state.colors[0];
|
|
1186
|
+
state.sel.layout = state.layouts.find(l=>l.id==='SC-006') || state.layouts[0];
|
|
1187
|
+
syncPicks();
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/* 三张卡片:未选时是虚线入口,选中后就地反显缩略图 + 编号 + 名称 */
|
|
1191
|
+
const CARD_KINDS = [['layout','Layout'], ['style','Style'], ['color','Color']];
|
|
1192
|
+
|
|
1193
|
+
function pickMeta(kind, it){
|
|
1194
|
+
if (!it) return {id:'', name:''};
|
|
1195
|
+
if (kind === 'layout') return {id: it.id, name: it.title || ''};
|
|
1196
|
+
if (kind === 'style') return {id: '#' + it.id, name: it.name_en || it.ref || ''};
|
|
1197
|
+
return {id: it.id, name: it.name || ''};
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function syncPicks(){
|
|
1201
|
+
CARD_KINDS.forEach(([k, sfx])=>{
|
|
1202
|
+
const it = state.sel[k];
|
|
1203
|
+
const m = pickMeta(k, it);
|
|
1204
|
+
$('#card'+sfx).classList.toggle('on', !!it);
|
|
1205
|
+
$('#body'+sfx).hidden = !it;
|
|
1206
|
+
$('#empty'+sfx).hidden = !!it;
|
|
1207
|
+
if (!it) return;
|
|
1208
|
+
$('#pid'+sfx).textContent = m.id;
|
|
1209
|
+
$('#pname'+sfx).textContent = m.name || '—';
|
|
1210
|
+
const img = $('#pimg'+sfx);
|
|
1211
|
+
if (img.getAttribute('data-src') !== it.img){
|
|
1212
|
+
img.setAttribute('data-src', it.img);
|
|
1213
|
+
img.style.visibility = 'visible';
|
|
1214
|
+
img.onerror = ()=>{ img.style.visibility = 'hidden'; };
|
|
1215
|
+
img.src = imgUrl(it.img);
|
|
1216
|
+
}
|
|
1217
|
+
img.alt = m.name || m.id;
|
|
1218
|
+
});
|
|
1219
|
+
if (state.sel.color && state.sel.color.prompt){
|
|
1220
|
+
$('#note').placeholder = '主题色:' + state.sel.color.name + '(可在下方写补充要求)';
|
|
1221
|
+
}
|
|
1222
|
+
syncInstr();
|
|
1223
|
+
noteContentChanged(); // 换图型 / 风格 / 主题色也算内容改动
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/* ---------- 参考图(可选):上传 / 拖入 / 粘贴 ---------- */
|
|
1227
|
+
function syncRef(){
|
|
1228
|
+
const r = state.ref;
|
|
1229
|
+
$('#cardRef').classList.toggle('on', !!r);
|
|
1230
|
+
$('#bodyRef').hidden = !r;
|
|
1231
|
+
$('#emptyRef').hidden = !!r;
|
|
1232
|
+
if (r){
|
|
1233
|
+
$('#pimgRef').src = r.url;
|
|
1234
|
+
$('#pnameRef').textContent = (r.w && r.h) ? (r.w + ' × ' + r.h) : '已上传';
|
|
1235
|
+
}
|
|
1236
|
+
syncInstr();
|
|
1237
|
+
noteContentChanged(); // 加 / 换 / 清参考图也要进草稿
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/* 压到 1400px 再传:原图动辄 5MB,号码池那边也要把它传进 ChatGPT,
|
|
1241
|
+
太大只会拖慢出图。PNG 太大就退回 JPEG,手绘线条够看。 */
|
|
1242
|
+
function shrinkImage(file, max){
|
|
1243
|
+
return new Promise((resolve, reject)=>{
|
|
1244
|
+
const fr = new FileReader();
|
|
1245
|
+
fr.onerror = ()=> reject(new Error('读取失败'));
|
|
1246
|
+
fr.onload = ()=>{
|
|
1247
|
+
const img = new Image();
|
|
1248
|
+
img.onerror = ()=> reject(new Error('不是有效图片'));
|
|
1249
|
+
img.onload = ()=>{
|
|
1250
|
+
let w = img.naturalWidth || img.width, h = img.naturalHeight || img.height;
|
|
1251
|
+
const k = Math.min(1, max / Math.max(w, h));
|
|
1252
|
+
w = Math.max(1, Math.round(w * k)); h = Math.max(1, Math.round(h * k));
|
|
1253
|
+
const c = document.createElement('canvas');
|
|
1254
|
+
c.width = w; c.height = h;
|
|
1255
|
+
const ctx = c.getContext('2d');
|
|
1256
|
+
ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h);
|
|
1257
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
1258
|
+
let url = c.toDataURL('image/png');
|
|
1259
|
+
if (url.length > 3.4e6) url = c.toDataURL('image/jpeg', 0.9);
|
|
1260
|
+
resolve(url);
|
|
1261
|
+
};
|
|
1262
|
+
img.src = fr.result;
|
|
1263
|
+
};
|
|
1264
|
+
fr.readAsDataURL(file);
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async function uploadRef(file){
|
|
1269
|
+
if (!file) return;
|
|
1270
|
+
if (!/^image\//.test(file.type || '')){ toast('只能上传图片'); return; }
|
|
1271
|
+
toast('参考图上传中…');
|
|
1272
|
+
try{
|
|
1273
|
+
const data = await shrinkImage(file, 1400);
|
|
1274
|
+
const r = await fetch('/api/ref', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
1275
|
+
body: JSON.stringify({data, name: file.name || ''})});
|
|
1276
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
1277
|
+
const d = await r.json();
|
|
1278
|
+
state.ref = {id: d.id, url: d.url, w: d.w, h: d.h};
|
|
1279
|
+
syncRef();
|
|
1280
|
+
toast('参考图就绪:出图会以它的画风为基准,和图型/风格一起融合');
|
|
1281
|
+
}catch(e){ toast('上传失败:' + e.message); }
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function clearRef(){ state.ref = null; syncRef(); }
|
|
1285
|
+
|
|
1286
|
+
function renderGroupChips(box, items, current, onPick){
|
|
1287
|
+
box.innerHTML = '';
|
|
1288
|
+
const groups = ['全部', ...new Set(items.map(s=>s.group_name))];
|
|
1289
|
+
groups.forEach(g=>{
|
|
1290
|
+
const n = g === '全部' ? items.length : items.filter(s=>s.group_name === g).length;
|
|
1291
|
+
const b = document.createElement('button');
|
|
1292
|
+
b.className = 'chip' + (g === current ? ' on' : '');
|
|
1293
|
+
b.innerHTML = `${g}<span class="n">${n}</span>`;
|
|
1294
|
+
b.onclick = ()=> onPick(g);
|
|
1295
|
+
box.appendChild(b);
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function pick(kind, item){
|
|
1300
|
+
if (!item) return;
|
|
1301
|
+
state.sel[kind] = item;
|
|
1302
|
+
state.selTouched = true;
|
|
1303
|
+
syncPicks();
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function drawText(text){
|
|
1307
|
+
/* 旧的 filter(Boolean) 会把 AI 返回里的空行全删光,
|
|
1308
|
+
而 splitDoc 的「无编号按空行切块」正是靠空行分段,等于自己把分段信号抹掉了。
|
|
1309
|
+
这里改成压缩连续空行为一个、保留段落结构。 */
|
|
1310
|
+
const raw = String(text || '').replace(/\r/g, '').split('\n').map(x => x.trim());
|
|
1311
|
+
const lines = [];
|
|
1312
|
+
raw.forEach(l => {
|
|
1313
|
+
if (!l){ if (lines.length && lines[lines.length - 1] !== '') lines.push(''); }
|
|
1314
|
+
else lines.push(l);
|
|
1315
|
+
});
|
|
1316
|
+
while (lines.length && !lines[0]) lines.shift();
|
|
1317
|
+
while (lines.length && !lines[lines.length - 1]) lines.pop();
|
|
1318
|
+
const title = lines.shift() || '';
|
|
1319
|
+
while (lines.length && !lines[0]) lines.shift();
|
|
1320
|
+
const body = lines.join('\n');
|
|
1321
|
+
$('#docTitle').value = title;
|
|
1322
|
+
$('#docBody').value = body;
|
|
1323
|
+
$('#docRaw').textContent = text;
|
|
1324
|
+
syncPreview();
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function syncPreview(){
|
|
1328
|
+
const title = $('#docTitle').value.trim();
|
|
1329
|
+
const body = $('#docBody').value;
|
|
1330
|
+
$('#pTitle').textContent = title || '标题会同步到这里';
|
|
1331
|
+
$('#pBody').textContent = (body.trim().slice(0, 4000) || '正文会同步到这里。');
|
|
1332
|
+
$('#pTags').textContent = body.split('\n').filter(l=>l.includes('#')).join(' ');
|
|
1333
|
+
noteContentChanged(); // 程序化改值(AI 生成 / 润色)不触发 input,这里补一脚
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/* ---------- 弹出选择(主题色 / 风格 / 模板) ---------- */
|
|
1337
|
+
function pickerItems(kind){
|
|
1338
|
+
return kind === 'style' ? state.styles : kind === 'layout' ? state.layouts : state.colors;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function openPicker(kind){
|
|
1342
|
+
state.picker = {kind, group:'全部'};
|
|
1343
|
+
const items = pickerItems(kind);
|
|
1344
|
+
const label = kind === 'style' ? '全部风格' : kind === 'layout' ? '全部图型' : '全部主题色';
|
|
1345
|
+
$('#pickerTitle').innerHTML = `${label} <span>共 ${items.length} 个</span>`;
|
|
1346
|
+
$('#pickerFilter').value = '';
|
|
1347
|
+
$('#pickerFilter').placeholder = kind === 'style' ? '搜索编号 / 名称 / 特征'
|
|
1348
|
+
: kind === 'color' ? '搜索编号 / 颜色名' : '搜索编号 / 名称';
|
|
1349
|
+
$('#pickerGrid').classList.toggle('color-grid', kind === 'color');
|
|
1350
|
+
renderPicker();
|
|
1351
|
+
$('#pickerDlg').showModal();
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function renderPicker(){
|
|
1355
|
+
const {kind, group} = state.picker;
|
|
1356
|
+
const norm = pickerItems(kind).map(it=>({...it, group_name: it.group_name || it.group || '全部'}));
|
|
1357
|
+
// 当前选中的那张提到第一位:点开就看见自己现在用的是哪一个,不用在几百个里翻
|
|
1358
|
+
const selId = state.sel[kind] && state.sel[kind].id;
|
|
1359
|
+
if (selId){
|
|
1360
|
+
const at = norm.findIndex(it=> it.id === selId);
|
|
1361
|
+
if (at > 0) norm.unshift(norm.splice(at, 1)[0]);
|
|
1362
|
+
}
|
|
1363
|
+
const q = $('#pickerFilter').value.trim().toLowerCase();
|
|
1364
|
+
renderGroupChips($('#pickerChips'), norm, group, g=>{ state.picker.group = g; renderPicker(); });
|
|
1365
|
+
const box = $('#pickerGrid'); box.innerHTML = '';
|
|
1366
|
+
const frag = document.createDocumentFragment();
|
|
1367
|
+
norm.forEach(it=>{
|
|
1368
|
+
if (group !== '全部' && it.group_name !== group) return;
|
|
1369
|
+
const hay = `${it.id} ${it.name_en || ''} ${it.features || ''} ${it.name || ''} ${it.title || ''}`.toLowerCase();
|
|
1370
|
+
if (q && !hay.includes(q)) return;
|
|
1371
|
+
const isSel = !!(selId && selId === it.id);
|
|
1372
|
+
const b = document.createElement('button');
|
|
1373
|
+
b.className = 'tile' + (isSel ? ' sel' : '') + (kind === 'color' ? ' round' : '');
|
|
1374
|
+
b.innerHTML = `<img loading="lazy" src="${imgUrl(it.img)}" alt="${it.id}"><span>${
|
|
1375
|
+
kind === 'color' ? `${it.id} ${it.name || ''}` : it.id}</span>${isSel ? '<i class="cur">当前</i>' : ''}`;
|
|
1376
|
+
b.onclick = ()=>{ pick(kind, it); $('#pickerDlg').close(); };
|
|
1377
|
+
frag.appendChild(b);
|
|
1378
|
+
});
|
|
1379
|
+
box.appendChild(frag);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function payload(mode, topic){
|
|
1383
|
+
return {
|
|
1384
|
+
mode, topic: (topic ?? $('#topic').value).trim(),
|
|
1385
|
+
group: state.group || $('#docTitle').value.trim() || undefined,
|
|
1386
|
+
style_id: state.sel.style && state.sel.style.id,
|
|
1387
|
+
layout_id: state.sel.layout && state.sel.layout.id,
|
|
1388
|
+
color_id: state.sel.color && state.sel.color.id,
|
|
1389
|
+
ratio: $('#ratio').value, note: $('#note').value.trim(),
|
|
1390
|
+
ref_id: state.ref ? state.ref.id : null,
|
|
1391
|
+
image_mode: state.omode || 'graphic-text',
|
|
1392
|
+
shoot_type: state.omode === 'photo' ? ($('#shootType').value || '').trim() : ''
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// 统一的出图提交入口:把后端返回的明确错误(如「尚未配置生图号池」)原样透出,
|
|
1397
|
+
// 而不是笼统的「提交失败 HTTP 400」,方便用户直接照提示操作。
|
|
1398
|
+
async function submitGenerate(p){
|
|
1399
|
+
const r = await fetch('/api/generate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(p)});
|
|
1400
|
+
if (!r.ok){
|
|
1401
|
+
let msg = '提交失败 HTTP ' + r.status;
|
|
1402
|
+
try { const d = await r.json(); if (d && d.detail) msg = d.detail; } catch(_){}
|
|
1403
|
+
throw new Error(msg);
|
|
1404
|
+
}
|
|
1405
|
+
return r.json();
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
async function run(mode, statusEl){
|
|
1409
|
+
const p = payload(mode);
|
|
1410
|
+
if (!p.topic){ toast('先写一个选题'); $('#topic').focus(); return; }
|
|
1411
|
+
statusEl.className = 'status run'; statusEl.textContent = '排队中…';
|
|
1412
|
+
try{
|
|
1413
|
+
const {job_id} = await submitGenerate(p);
|
|
1414
|
+
poll(job_id, statusEl, mode);
|
|
1415
|
+
}catch(e){ statusEl.className='status err'; statusEl.textContent = e.message; toast(e.message, 'err'); }
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
async function poll(id, statusEl, mode){
|
|
1419
|
+
// 失败会自动换号重试(15s / 40s / 90s),四次尝试最坏接近 7 分钟,
|
|
1420
|
+
// 轮询预算给到 15 分钟,别把还在重试的任务当成超时
|
|
1421
|
+
for (let i=0;i<450;i++){
|
|
1422
|
+
await new Promise(r=>setTimeout(r, 2000));
|
|
1423
|
+
let d;
|
|
1424
|
+
try { d = await (await fetch('/api/jobs/' + id)).json(); } catch { continue; }
|
|
1425
|
+
if (d.status === 'queued'){
|
|
1426
|
+
statusEl.textContent = d.auto_retry ? `换号重试中(第 ${d.auto_retry}/3 次)…` : '排队中…';
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1429
|
+
if (d.status === 'running'){ statusEl.textContent = '生成中 · ' + (d.account || ''); continue; }
|
|
1430
|
+
if (d.status === 'done'){
|
|
1431
|
+
statusEl.className = 'status ok';
|
|
1432
|
+
statusEl.textContent = '完成 · ' + (d.account || '');
|
|
1433
|
+
if (mode !== 'image'){ drawText(d.result.text || ''); $('#textAccount').textContent = d.account || ''; }
|
|
1434
|
+
else {
|
|
1435
|
+
// job id 唯一,URL 天然不会撞缓存;去掉 ?t= 时间戳,浏览器才能复用派生图
|
|
1436
|
+
const raw = d.image_url;
|
|
1437
|
+
const prev = d.preview_url || ('/api/history/' + id + '/preview');
|
|
1438
|
+
setCover(prev, raw);
|
|
1439
|
+
const dl = $('#downloadBtn'); dl.href = downloadHref({id}, raw); dl.style.display='block';
|
|
1440
|
+
$('#imageStatus').textContent = '完成 · ' + (d.account || '');
|
|
1441
|
+
setPoolDetail('本轮账号 ' + (d.account || '未知') + ' · ' + (d.result.width||'?') + '×' + (d.result.height||'?'));
|
|
1442
|
+
}
|
|
1443
|
+
const isText = mode !== 'image';
|
|
1444
|
+
toast(isText ? '文案已生成,可直接复制到编辑器' : '配图已生成,右侧就是成品',
|
|
1445
|
+
{type: 'ok', title: isText ? '任务完成' : '出图完成'});
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
if (d.status === 'error'){
|
|
1449
|
+
const who = [...new Set((d.attempts || []).map(a=>a.account).filter(Boolean))];
|
|
1450
|
+
statusEl.className = 'status err';
|
|
1451
|
+
statusEl.textContent = '失败:' + (d.error || '未知错误');
|
|
1452
|
+
toast(who.length ? `换过 ${who.length} 个号:${who.join('、')}` : '生成失败:' + (d.error || ''),
|
|
1453
|
+
{type: 'err', title: '任务失败', ms: 6500});
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
statusEl.className = 'status err'; statusEl.textContent = '超时未完成';
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
async function polishText(){
|
|
1461
|
+
const title = $('#docTitle').value.trim();
|
|
1462
|
+
const body = $('#docBody').value.trim();
|
|
1463
|
+
if (!title && !body){ toast('先写标题或正文'); $('#docTitle').focus(); return; }
|
|
1464
|
+
const statusEl = $('#textStatus');
|
|
1465
|
+
statusEl.className = 'status run'; statusEl.textContent = '排队中…';
|
|
1466
|
+
try{
|
|
1467
|
+
const {job_id} = await submitGenerate({
|
|
1468
|
+
mode:'polish', topic: title || body.slice(0, 60), title, body,
|
|
1469
|
+
style_id: null, layout_id: null, color_id: null, ratio: '3:4', note: null
|
|
1470
|
+
});
|
|
1471
|
+
poll(job_id, statusEl, 'polish');
|
|
1472
|
+
}catch(e){
|
|
1473
|
+
statusEl.className = 'status err'; statusEl.textContent = e.message;
|
|
1474
|
+
toast(e.message, 'err');
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/* ---------- 分镜清单 & 提示词组装 ---------- */
|
|
1479
|
+
const MOOD_PRESETS = ['治愈','童趣','松弛','幽默','诗意','浪漫','活力','微丧','孤独','紧张','庄严','荒诞','神秘','激烈'];
|
|
1480
|
+
/* 默认组合:情绪「治愈、松弛、幽默」,缩微摄影融合在图文 / 摄影两种模式下都勾上。
|
|
1481
|
+
HTML 上的 value / checked 就是默认值,专门防忘记;复选框始终可取消。
|
|
1482
|
+
用户手动动过融合复选框后,用 sessionStorage 在当前标签页记住他的选择:
|
|
1483
|
+
刷新不再强制勾回去,关掉标签页重新打开才回到默认勾上。 */
|
|
1484
|
+
const DEFAULT_MOOD = '治愈、松弛、幽默';
|
|
1485
|
+
function applyDefaults(){
|
|
1486
|
+
$('#mood').value = DEFAULT_MOOD;
|
|
1487
|
+
$('#fusion').checked = true;
|
|
1488
|
+
try{ sessionStorage.removeItem('wb_fusion_user'); }catch(e){}
|
|
1489
|
+
}
|
|
1490
|
+
/* 拍摄类型:照搬 handraw-style/gallery/tutorials.html 摄影模式的 10 个预设,不另写 */
|
|
1491
|
+
const SHOOT_PRESETS = ['情侣','婚纱','个人写真','亲子全家福','古风汉服','新中式','复古港风','Cosplay','旅行抓拍','职场肖像'];
|
|
1492
|
+
/* 封面条识别:优先看对象上的 cover 标记;老记录没有标记,
|
|
1493
|
+
靠「标题以 00 开头」或「提示词以主视觉要求开头」兜底(历史载入、导入回贴都用得上) */
|
|
1494
|
+
const COVER_LEAD = '这张是整组的主视觉大图:标题文字是画面绝对主角,占画面一半以上,构图饱满醒目,装饰元素直接呼应标题的意思,除标题外不要出现成段文字。';
|
|
1495
|
+
function isCoverItem(it){
|
|
1496
|
+
if (!it) return false;
|
|
1497
|
+
if (it.cover) return true;
|
|
1498
|
+
if (/^00[\s..、,,::||]/.test((it.title || '').trim())) return true;
|
|
1499
|
+
return !!(it.prompt || '').startsWith(COVER_LEAD);
|
|
1500
|
+
}
|
|
1501
|
+
/* 主视觉前缀只属于图文:摄影模式图里不排文字,加上会互相打架 */
|
|
1502
|
+
function leadFor(it){
|
|
1503
|
+
return state.omode === 'photo' ? '' : (isCoverItem(it) ? COVER_LEAD : '');
|
|
1504
|
+
}
|
|
1505
|
+
/* 条目编号:封面固定 00;正文优先取标题里自带的(01 02…),没有才按位置数 */
|
|
1506
|
+
function itemNo(it, i){
|
|
1507
|
+
if (isCoverItem(it)) return '00';
|
|
1508
|
+
const m = (it.title || '').trim().match(/^0?\d{1,2}(?=[\s..、,,::||]|$)/);
|
|
1509
|
+
if (m) return m[0].padStart(2, '0');
|
|
1510
|
+
const off = state.items[0] && isCoverItem(state.items[0]) ? 0 : 1;
|
|
1511
|
+
return String(Math.max(1, i + off)).padStart(2, '0');
|
|
1512
|
+
}
|
|
1513
|
+
state.items = [];
|
|
1514
|
+
state.ref = null; // 参考图:{id, url, w, h},可选
|
|
1515
|
+
state.ws = 'normal';
|
|
1516
|
+
state.group = '';
|
|
1517
|
+
state.mode = 'image';
|
|
1518
|
+
/* 出图模式:graphic-text 图文(默认,行为与以前一致)/ photo 摄影 */
|
|
1519
|
+
state.omode = 'graphic-text';
|
|
1520
|
+
/* 记住当前正在编辑哪份草稿,以及它的「身份键」(选题,空则用标题)。
|
|
1521
|
+
暂存时比对身份键:同一篇继续改就更新那份,换了选题就另存新的,
|
|
1522
|
+
避免把上一篇草稿悄悄覆盖掉(选题变了还写回旧 id 是最坑的一种)。 */
|
|
1523
|
+
try{
|
|
1524
|
+
state.draftId = localStorage.getItem('wb_draft_id') || null;
|
|
1525
|
+
state.draftKey = localStorage.getItem('wb_draft_key') || '';
|
|
1526
|
+
}catch(e){ state.draftId = null; state.draftKey = ''; }
|
|
1527
|
+
state.tasks = {page:1, limit:20, pages:1, total:0, items:[], q:''};
|
|
1528
|
+
|
|
1529
|
+
function moods(){
|
|
1530
|
+
return ($('#mood').value || '').split(/[、,,\/\s]+/).map(s=>s.trim()).filter(Boolean);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function renderMoodChips(){
|
|
1534
|
+
const cur = new Set(moods());
|
|
1535
|
+
const box = $('#moodChips'); box.innerHTML = '';
|
|
1536
|
+
MOOD_PRESETS.forEach(m=>{
|
|
1537
|
+
const b = document.createElement('button');
|
|
1538
|
+
b.type = 'button';
|
|
1539
|
+
b.className = 'chip' + (cur.has(m) ? ' on' : '');
|
|
1540
|
+
b.textContent = m;
|
|
1541
|
+
b.onclick = ()=>{
|
|
1542
|
+
const set = new Set(moods());
|
|
1543
|
+
set.has(m) ? set.delete(m) : set.add(m);
|
|
1544
|
+
$('#mood').value = [...set].join('、');
|
|
1545
|
+
renderMoodChips(); syncInstr();
|
|
1546
|
+
};
|
|
1547
|
+
box.appendChild(b);
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/* 拍摄类型:点标签填进输入框,再点一次取消;手写字和标签互相覆盖,同一个值 */
|
|
1552
|
+
function renderShootChips(){
|
|
1553
|
+
const box = $('#shootChips');
|
|
1554
|
+
if (!box) return;
|
|
1555
|
+
const cur = ($('#shootType').value || '').trim();
|
|
1556
|
+
box.innerHTML = '';
|
|
1557
|
+
SHOOT_PRESETS.forEach(s=>{
|
|
1558
|
+
const b = document.createElement('button');
|
|
1559
|
+
b.type = 'button';
|
|
1560
|
+
b.className = 'chip' + (cur === s ? ' on' : '');
|
|
1561
|
+
b.textContent = s;
|
|
1562
|
+
b.onclick = ()=>{
|
|
1563
|
+
$('#shootType').value = cur === s ? '' : s;
|
|
1564
|
+
renderShootChips(); syncInstr();
|
|
1565
|
+
};
|
|
1566
|
+
box.appendChild(b);
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/* 图文 / 摄影切换:只动显隐,不改任何已有输入,切回图文就是原样 */
|
|
1571
|
+
function syncOmode(){
|
|
1572
|
+
const photo = state.omode === 'photo';
|
|
1573
|
+
document.querySelectorAll('#omodeSeg button').forEach(b=>{
|
|
1574
|
+
b.classList.toggle('on', b.dataset.omode === state.omode);
|
|
1575
|
+
});
|
|
1576
|
+
$('#shootWrap').hidden = !photo;
|
|
1577
|
+
const hint = $('#omodeHint');
|
|
1578
|
+
if (hint) hint.textContent = photo ? '按拍摄类型出写真:画里不排文字,也不套图型版式' : '图里排标题和正文文字';
|
|
1579
|
+
// 缩微摄影融合两种模式都可用,不再随摄影模式隐藏
|
|
1580
|
+
const fr = $('#fusionRow');
|
|
1581
|
+
if (fr) fr.hidden = false;
|
|
1582
|
+
noteContentChanged(); // 出图模式也是草稿的一部分,改了要存
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function buildInstr(){
|
|
1586
|
+
const bits = [state.omode === 'photo' ? '摄影写真模式' : '图文模式'];
|
|
1587
|
+
if (state.ref) bits.push('参考图:已上传');
|
|
1588
|
+
if (state.sel.layout && state.omode !== 'photo') bits.push('图型:' + state.sel.layout.id);
|
|
1589
|
+
if (state.sel.style) bits.push('风格:' + state.sel.style.id);
|
|
1590
|
+
if (state.sel.color) bits.push('主题色:' + state.sel.color.id);
|
|
1591
|
+
// 三张卡都可以不选:说清楚这时出图靠什么(有参考图就跟参考图,没有就不限)
|
|
1592
|
+
if (!state.sel.layout && !state.sel.style && !state.sel.color){
|
|
1593
|
+
bits.push(state.ref ? '图型 / 风格 / 主题色:跟参考图' : '图型 / 风格 / 主题色:不限');
|
|
1594
|
+
}
|
|
1595
|
+
const r = $('#ratio').value; if (r) bits.push('画幅比例:' + r);
|
|
1596
|
+
const m = moods(); if (m.length) bits.push('情绪:' + m.join('、'));
|
|
1597
|
+
if (state.omode === 'photo'){
|
|
1598
|
+
const s = ($('#shootType').value || '').trim();
|
|
1599
|
+
if (s) bits.push('拍摄类型:' + s);
|
|
1600
|
+
}
|
|
1601
|
+
if ($('#fusion').checked) bits.push('缩微摄影融合');
|
|
1602
|
+
if (state.ws === 'moderate') bits.push('留白:大量');
|
|
1603
|
+
else if (state.ws === 'high') bits.push('留白:极简');
|
|
1604
|
+
return bits.join(',');
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function syncInstr(){
|
|
1608
|
+
const el = $('#instrOut');
|
|
1609
|
+
if (state.instrCustom !== null){
|
|
1610
|
+
el.textContent = state.instrCustom;
|
|
1611
|
+
}else{
|
|
1612
|
+
el.textContent = buildInstr() || '图型 / 风格 / 主题色都可以不选,这里会写出这一轮靠什么出图,也能直接手改';
|
|
1613
|
+
}
|
|
1614
|
+
instrUi();
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/* 画幅一变,右侧手机预览的封面框跟着换比例:2.35:1 是横幅,预览还按 3:4 显示
|
|
1618
|
+
就看不出裁完到底是什么样。公众号首图的尺寸口径也在这里说明白。 */
|
|
1619
|
+
const RATIO_META = {
|
|
1620
|
+
'2.35:1': {ar: 2.35, bar: '微信公众号 · 首图 900×383',
|
|
1621
|
+
note: '公众号首图按 2.35:1 出(等比 900×383)。发布后分享小图 1:1 由微信从首图自动裁,不用单独出图。'},
|
|
1622
|
+
};
|
|
1623
|
+
function syncRatioUi(){
|
|
1624
|
+
const v = $('#ratio').value || '3:4';
|
|
1625
|
+
const meta = RATIO_META[v] || null;
|
|
1626
|
+
let ar = 3 / 4;
|
|
1627
|
+
if (meta) ar = meta.ar;
|
|
1628
|
+
else if (v.includes(':')){
|
|
1629
|
+
const [a, b] = v.split(':').map(parseFloat);
|
|
1630
|
+
if (a > 0 && b > 0) ar = a / b;
|
|
1631
|
+
}
|
|
1632
|
+
const cover = $('#cover');
|
|
1633
|
+
if (cover) cover.style.aspectRatio = ar;
|
|
1634
|
+
const bar = $('#phoneBar');
|
|
1635
|
+
if (bar) bar.textContent = meta ? meta.bar : '小红书 · 预览';
|
|
1636
|
+
const note = $('#ratioNote');
|
|
1637
|
+
if (note){ note.hidden = !meta; note.textContent = meta ? meta.note : ''; }
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/* 下载一律走成品:2.35:1 由服务端裁好再给,直接拿 /files 原图会是没裁的 3:2 */
|
|
1641
|
+
function downloadHref(rec, fallback){
|
|
1642
|
+
if (rec && rec.id) return '/api/history/' + rec.id + '/download';
|
|
1643
|
+
return fallback || '#';
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/* '2.35:1' → '2.35 / 1':历史卡片按当时的画幅留框,横幅图不会被 3:4 框 cover 成一条 */
|
|
1647
|
+
function arCss(v){
|
|
1648
|
+
const s = String(v || '');
|
|
1649
|
+
if (!s.includes(':')) return '';
|
|
1650
|
+
const [a, b] = s.split(':').map(parseFloat);
|
|
1651
|
+
return (a > 0 && b > 0) ? a + ' / ' + b : '';
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/* 手改状态下,组合变化不覆盖用户输入;点「还原」回到自动拼接 */
|
|
1655
|
+
function instrUi(){
|
|
1656
|
+
const hint = $('#instrHint'), rs = $('#resetInstr');
|
|
1657
|
+
const custom = state.instrCustom !== null;
|
|
1658
|
+
if (hint) hint.textContent = custom ? '已手改 · 组合变化不覆盖' : '组合一变就更新,可直接手改';
|
|
1659
|
+
if (rs) rs.hidden = !custom;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function composeBody(items){
|
|
1663
|
+
return {
|
|
1664
|
+
style_id: state.sel.style ? state.sel.style.id : null,
|
|
1665
|
+
layout_id: state.sel.layout ? state.sel.layout.id : null,
|
|
1666
|
+
color_id: state.sel.color ? state.sel.color.id : null,
|
|
1667
|
+
ratio: $('#ratio').value,
|
|
1668
|
+
mood: $('#mood').value,
|
|
1669
|
+
fusion: $('#fusion').checked,
|
|
1670
|
+
whitespace: state.ws,
|
|
1671
|
+
note: $('#note').value.trim(),
|
|
1672
|
+
ref: !!state.ref,
|
|
1673
|
+
image_mode: state.omode || 'graphic-text',
|
|
1674
|
+
shoot_type: state.omode === 'photo' ? ($('#shootType').value || '').trim() : '',
|
|
1675
|
+
instruction: (state.instrCustom || '').trim(),
|
|
1676
|
+
allow_blank: true,
|
|
1677
|
+
items: items.map(it=>({title: it.title || '', body: it.body || '', scene: it.scene || ''}))
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async function postCompose(items){
|
|
1682
|
+
const r = await fetch('/api/compose', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
1683
|
+
body: JSON.stringify(composeBody(items))});
|
|
1684
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
1685
|
+
return r.json();
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function addItem(title, body, scene){
|
|
1689
|
+
state.items.push({title: title || '', body: body || '', scene: scene || '', prompt: ''});
|
|
1690
|
+
renderItems();
|
|
1691
|
+
const rows = document.querySelectorAll('.item');
|
|
1692
|
+
const last = rows[rows.length - 1];
|
|
1693
|
+
if (last && !title) last.querySelector('.it-title').focus();
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/* 提示词框随内容长高,别让用户滚动才能看全 */
|
|
1697
|
+
function autoGrow(el, max){
|
|
1698
|
+
if (!el) return;
|
|
1699
|
+
el.style.height = 'auto';
|
|
1700
|
+
el.style.height = Math.min(el.scrollHeight + 2, max || 460) + 'px';
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/* 正文框:一行就一行高,最多两行,再多就自己滚动 */
|
|
1704
|
+
const BODY_MAX = 62;
|
|
1705
|
+
function fitBody(el){
|
|
1706
|
+
if (!el) return;
|
|
1707
|
+
/* 先把高度压到 0 才能量出真实内容高度:textarea 的 scrollHeight 永远不小于
|
|
1708
|
+
自身高度,不清 min-height 就永远读到两行(rows=2 的默认高度)。 */
|
|
1709
|
+
el.style.minHeight = '0';
|
|
1710
|
+
el.style.height = '0px';
|
|
1711
|
+
const h = el.scrollHeight + 2;
|
|
1712
|
+
el.style.height = Math.min(h, BODY_MAX) + 'px';
|
|
1713
|
+
el.style.overflowY = h > BODY_MAX ? 'auto' : 'hidden';
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function renderItems(){
|
|
1717
|
+
const box = $('#itemList'); box.innerHTML = '';
|
|
1718
|
+
const total = state.items.length;
|
|
1719
|
+
state.items.forEach((it, i)=>{
|
|
1720
|
+
const el = document.createElement('div');
|
|
1721
|
+
el.className = 'item';
|
|
1722
|
+
el.dataset.i = i;
|
|
1723
|
+
const no = itemNo(it, i);
|
|
1724
|
+
const tag = no + '/' + String(total).padStart(2, '0');
|
|
1725
|
+
const cover = isCoverItem(it);
|
|
1726
|
+
el.innerHTML = '<div class="it-top">'
|
|
1727
|
+
+ '<span class="it-no"' + (/^\d{1,2}[\s..、,,::-]/.test((it.title || '').trim()) ? ' hidden' : '') + '>'
|
|
1728
|
+
+ no + '</span>'
|
|
1729
|
+
+ '<input class="it-title" placeholder="标题,如:先存后花">'
|
|
1730
|
+
+ '<button class="mini it-gen" type="button" title="只出这一张,先看看效果">出图</button>'
|
|
1731
|
+
+ '<button class="it-del" type="button" title="删除这条">×</button>'
|
|
1732
|
+
+ '</div>'
|
|
1733
|
+
+ '<textarea class="it-body" rows="2" placeholder="这张图要说什么:画面里的人或物、动作、细节,以及图里要出现的那句话"></textarea>'
|
|
1734
|
+
+ '<input class="it-scene" placeholder="中层小辅助图(可选):涂鸦小人把一枚硬币投进小猪储蓄罐,旁边一张工资条">'
|
|
1735
|
+
+ '<div class="it-pw' + (it.prompt ? '' : ' blank') + '">'
|
|
1736
|
+
+ '<div class="it-ph"><span>' + (cover ? '主图 ' : '卡片 ') + tag + ' · '
|
|
1737
|
+
+ (it.prompt ? '提示词(可手改,出图就用这一版)' : '提示词(留空自动拼,写了就用你这份)')
|
|
1738
|
+
+ '</span>'
|
|
1739
|
+
+ '<button type="button" class="mini it-copy">复制</button>'
|
|
1740
|
+
+ '<button type="button" class="mini it-re">重拼</button></div>'
|
|
1741
|
+
+ '<textarea class="it-prompt" rows="6" placeholder="可直接手写详细提示词:画面内容、人物动作、版式、配色、要出现的文字都写清楚。写了就不再自动拼,点批量出图原样使用。"></textarea>'
|
|
1742
|
+
+ '</div>';
|
|
1743
|
+
const tEl = el.querySelector('.it-title');
|
|
1744
|
+
const bEl = el.querySelector('.it-body');
|
|
1745
|
+
const sEl = el.querySelector('.it-scene');
|
|
1746
|
+
const pEl = el.querySelector('.it-prompt');
|
|
1747
|
+
tEl.value = it.title; bEl.value = it.body; sEl.value = it.scene || '';
|
|
1748
|
+
pEl.value = it.prompt || '';
|
|
1749
|
+
tEl.oninput = ()=>{ state.items[i].title = tEl.value; };
|
|
1750
|
+
bEl.oninput = ()=>{ state.items[i].body = bEl.value; fitBody(bEl); };
|
|
1751
|
+
sEl.oninput = ()=>{ state.items[i].scene = sEl.value; };
|
|
1752
|
+
pEl.oninput = ()=>{ state.items[i].prompt = pEl.value; autoGrow(pEl); };
|
|
1753
|
+
el.querySelector('.it-del').onclick = ()=>{
|
|
1754
|
+
state.items.splice(i, 1);
|
|
1755
|
+
renderItems();
|
|
1756
|
+
};
|
|
1757
|
+
el.querySelector('.it-copy').onclick = ()=> copyText(state.items[i].prompt, '提示词已复制');
|
|
1758
|
+
el.querySelector('.it-re').onclick = ()=> recomposeOne(i);
|
|
1759
|
+
el.querySelector('.it-gen').onclick = ()=> runOne(i);
|
|
1760
|
+
box.appendChild(el);
|
|
1761
|
+
if (it.prompt) autoGrow(pEl);
|
|
1762
|
+
fitBody(bEl);
|
|
1763
|
+
});
|
|
1764
|
+
$('#itemEmpty').style.display = state.items.length ? 'none' : 'block';
|
|
1765
|
+
$('#itemCount').textContent = state.items.length + ' 条';
|
|
1766
|
+
saveSessionSoon();
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
async function recomposeOne(i){
|
|
1770
|
+
const it = state.items[i];
|
|
1771
|
+
if (!it) return;
|
|
1772
|
+
if ((it.prompt || '').trim() && !confirm('第 ' + (i + 1) + ' 条已有提示词(可能是你手写的),重拼会整条覆盖,继续?')) return;
|
|
1773
|
+
try{
|
|
1774
|
+
const d = await postCompose([it]);
|
|
1775
|
+
state.items[i].prompt = leadFor(it) + d.items[0].prompt;
|
|
1776
|
+
renderItems();
|
|
1777
|
+
toast('第 ' + (i + 1) + ' 条已重拼');
|
|
1778
|
+
}catch(e){ toast('重拼失败:' + e.message); }
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/* 单条出图:只想先看一张效果时用。结果替换预览里同一序号那张旧图。
|
|
1782
|
+
单张重做模式下这次出图会原位替换历史任务里的那一张,不新增历史记录。 */
|
|
1783
|
+
async function runOne(i){
|
|
1784
|
+
if (!redoGuard()) return;
|
|
1785
|
+
const it = state.items[i];
|
|
1786
|
+
if (!it) return;
|
|
1787
|
+
let prompt = (it.prompt || '').trim();
|
|
1788
|
+
if (!prompt){
|
|
1789
|
+
try{
|
|
1790
|
+
const d = await postCompose([it]);
|
|
1791
|
+
prompt = leadFor(it) + d.items[0].prompt;
|
|
1792
|
+
state.items[i].prompt = prompt;
|
|
1793
|
+
renderItems();
|
|
1794
|
+
}catch(e){ toast('拼提示词失败:' + e.message); return; }
|
|
1795
|
+
}
|
|
1796
|
+
const redo = state.redo;
|
|
1797
|
+
prompt = await redoPrompt(it, redo, prompt);
|
|
1798
|
+
const statusEl = $('#imageStatus');
|
|
1799
|
+
statusEl.className = 'status run';
|
|
1800
|
+
statusEl.textContent = '第 ' + (i + 1) + ' 张准备出图…';
|
|
1801
|
+
const taskId = 'k' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
1802
|
+
const topic = (it.title || it.body || '配图').slice(0, 60);
|
|
1803
|
+
try{
|
|
1804
|
+
const {job_id} = await submitGenerate({...payload('image', topic), task_id: taskId, seq: i,
|
|
1805
|
+
doc_body: $('#docBody').value, title: it.title, body: it.body, prompt,
|
|
1806
|
+
...redoExtra(redo)});
|
|
1807
|
+
const ok = await pollOne(job_id, i, topic, statusEl);
|
|
1808
|
+
statusEl.className = ok ? 'status ok' : 'status err';
|
|
1809
|
+
if (ok) clearRedo(true);
|
|
1810
|
+
}catch(e){
|
|
1811
|
+
statusEl.className = 'status err'; statusEl.textContent = e.message;
|
|
1812
|
+
toast(e.message, 'err');
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
async function pollOne(id, i, topic, statusEl){
|
|
1817
|
+
// 失败会自动换号重试(15s / 40s / 90s 三档),四次尝试最坏接近 7 分钟,预算给足 15 分钟
|
|
1818
|
+
for (let t = 0; t < 450; t++){
|
|
1819
|
+
await new Promise(r=>setTimeout(r, 2000));
|
|
1820
|
+
let d;
|
|
1821
|
+
try { d = await (await fetch('/api/jobs/' + id)).json(); } catch { continue; }
|
|
1822
|
+
if (d.status === 'queued'){
|
|
1823
|
+
statusEl.textContent = '第 ' + (i + 1) + ' 张'
|
|
1824
|
+
+ (d.auto_retry ? `换号重试中(第 ${d.auto_retry}/3 次)` : '排队中') + '…';
|
|
1825
|
+
continue;
|
|
1826
|
+
}
|
|
1827
|
+
if (d.status === 'running'){
|
|
1828
|
+
statusEl.textContent = '第 ' + (i + 1) + ' 张生成中 · ' + (d.account || '');
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
if (d.status === 'done'){
|
|
1832
|
+
const url = d.image_url + '?t=' + Date.now();
|
|
1833
|
+
const rec = {url, topic, seq: i, id, account: d.account || '',
|
|
1834
|
+
w: d.result && d.result.width, h: d.result && d.result.height};
|
|
1835
|
+
const old = state.results.findIndex(x=> x.seq === i);
|
|
1836
|
+
if (old >= 0) state.results[old] = rec; else state.results.push(rec);
|
|
1837
|
+
state.results.sort((a,b)=>(a.seq??0)-(b.seq??0));
|
|
1838
|
+
state.idx = state.results.indexOf(rec);
|
|
1839
|
+
renderStrip();
|
|
1840
|
+
statusEl.textContent = '第 ' + (i + 1) + ' 张完成 · ' + (d.account || '');
|
|
1841
|
+
const swapped = (d.attempts || []).length;
|
|
1842
|
+
toast(topic + (d.account ? ' · ' + d.account : '')
|
|
1843
|
+
+ (swapped ? ` · 换号 ${swapped} 次后成功` : ''),
|
|
1844
|
+
{type: 'ok', title: '第 ' + (i + 1) + ' 张出图完成'});
|
|
1845
|
+
return true;
|
|
1846
|
+
}
|
|
1847
|
+
const who = [...new Set((d.attempts || []).map(a=>a.account).filter(Boolean))];
|
|
1848
|
+
statusEl.textContent = '第 ' + (i + 1) + ' 张失败:' + (d.error || '未知错误');
|
|
1849
|
+
toast(who.length ? `换过 ${who.length} 个号:${who.join('、')}`
|
|
1850
|
+
: ('第 ' + (i + 1) + ' 张出图失败:' + (d.error || '')),
|
|
1851
|
+
{type: 'err', title: `第 ${i + 1} 张出图失败`, ms: 6500});
|
|
1852
|
+
return false;
|
|
1853
|
+
}
|
|
1854
|
+
statusEl.textContent = '第 ' + (i + 1) + ' 张超时未完成';
|
|
1855
|
+
return false;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
async function composeAll(){
|
|
1859
|
+
if (!state.items.length){
|
|
1860
|
+
// 清单为空但有选型 / 参考图 / 手改指令:自动补一条空白分镜,直接按出图指令拼
|
|
1861
|
+
const hasSel = state.sel.style || state.sel.layout || state.sel.color;
|
|
1862
|
+
if (!hasSel && !state.ref && !(state.instrCustom || '').trim()){
|
|
1863
|
+
toast('先写标题正文,或选图型 / 风格 / 主题色,或上传参考图'); return;
|
|
1864
|
+
}
|
|
1865
|
+
state.items = [{title: '', body: '', scene: '', prompt: ''}];
|
|
1866
|
+
renderItems();
|
|
1867
|
+
}
|
|
1868
|
+
const allBlank = state.items.every(x=> !(x.title||'').trim() && !(x.body||'').trim());
|
|
1869
|
+
// 只有参考图也放行:画风基准来自参考图,文案以后再补(用户 2026-11-08 要求)
|
|
1870
|
+
if (allBlank && !state.sel.style && !state.sel.layout && !state.sel.color
|
|
1871
|
+
&& !state.ref && !(state.instrCustom||'').trim()){
|
|
1872
|
+
toast('先写标题正文,或选图型 / 风格 / 主题色,或上传参考图'); return;
|
|
1873
|
+
}
|
|
1874
|
+
const written = state.items.filter(x=> (x.prompt || '').trim()).length;
|
|
1875
|
+
if (written && !confirm('会用图型/风格重拼 ' + state.items.length + ' 条提示词,其中 '
|
|
1876
|
+
+ written + ' 条已有内容(含手写的)会被覆盖,继续?')) return;
|
|
1877
|
+
const st = $('#imageStatus');
|
|
1878
|
+
st.className = 'status run'; st.textContent = '拼装中…';
|
|
1879
|
+
try{
|
|
1880
|
+
const d = await postCompose(state.items);
|
|
1881
|
+
d.items.forEach((x, i)=>{
|
|
1882
|
+
if (!state.items[i]) return;
|
|
1883
|
+
state.items[i].prompt = leadFor(state.items[i]) + x.prompt;
|
|
1884
|
+
});
|
|
1885
|
+
renderItems();
|
|
1886
|
+
st.className = 'status ok';
|
|
1887
|
+
st.textContent = '已生成 ' + d.items.length + ' 条提示词';
|
|
1888
|
+
toast('提示词已生成,可逐条手改后再出图');
|
|
1889
|
+
}catch(e){
|
|
1890
|
+
st.className = 'status err'; st.textContent = '拼装失败:' + e.message;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/* 整段清单 → 多条卡片。支持 01|标题 / 1、标题 / **01|标题** / 【01】标题
|
|
1895
|
+
编号必须递增才算新的一条,避免正文里「3、xxx」被误判成标题 */
|
|
1896
|
+
const ITEM_HEAD_RE = /^[**【\[((]*\s*(\d{1,2})\s*[||、,,..::))]\s*(.+?)\s*[**】\]))]*$/;
|
|
1897
|
+
|
|
1898
|
+
function parseImport(text){
|
|
1899
|
+
const full = String(text || '').replace(/\r/g, '');
|
|
1900
|
+
|
|
1901
|
+
/* 「复制全部」导出的格式:**卡片 01/08 | 标题** + ```围栏```里是完整提示词。
|
|
1902
|
+
正文文字:「……」还能抠回来当正文,之后重拼也有原料。 */
|
|
1903
|
+
if (/\*\*?卡片\s*\d{1,2}\s*\/\s*\d{1,2}\s*[||]/.test(full)){
|
|
1904
|
+
const out = [];
|
|
1905
|
+
const re = /\*\*?卡片\s*(\d{1,2})\s*\/\s*\d{1,2}\s*[||]\s*(.+?)\*\*?([\s\S]*?)(?=\*\*?卡片\s*\d{1,2}\s*\/|$)/g;
|
|
1906
|
+
let m;
|
|
1907
|
+
while ((m = re.exec(full))){
|
|
1908
|
+
const seg = m[3] || '';
|
|
1909
|
+
const fence = seg.match(/```[^\n]*\n([\s\S]*?)```/);
|
|
1910
|
+
const bodyM = seg.match(/正文文字[::]\s*[「\u201c]([\s\S]*?)[」\u201d]/);
|
|
1911
|
+
out.push({
|
|
1912
|
+
title: m[1] === '00' ? m[2].trim() : (m[1] + ' ' + m[2]).trim(),
|
|
1913
|
+
cover: m[1] === '00' || undefined,
|
|
1914
|
+
body: bodyM ? bodyM[1].trim() : '',
|
|
1915
|
+
prompt: fence ? fence[1].trim() : ''
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
if (out.length > 1) return out;
|
|
1919
|
+
/* 只有一个说明多半是误判(正文里正好出现「卡片 01/08」),走下面的常规解析 */
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
const out = [];
|
|
1923
|
+
let cur = null, lastNum = 0;
|
|
1924
|
+
full.split('\n').forEach(raw=>{
|
|
1925
|
+
const line = raw.trim();
|
|
1926
|
+
if (!line) return;
|
|
1927
|
+
const m = line.match(ITEM_HEAD_RE);
|
|
1928
|
+
const num = m ? parseInt(m[1], 10) : -1;
|
|
1929
|
+
const isHead = !!m && (!cur || num === lastNum + 1);
|
|
1930
|
+
if (isHead){
|
|
1931
|
+
if (cur) out.push(cur);
|
|
1932
|
+
cur = {title: (m[1] + ' ' + m[2]).replace(/[**]/g, '').trim(), body: ''};
|
|
1933
|
+
lastNum = num;
|
|
1934
|
+
} else if (cur){
|
|
1935
|
+
cur.body = cur.body ? cur.body + '\n' + line : line;
|
|
1936
|
+
} else {
|
|
1937
|
+
cur = {title: line.replace(/[**]/g, '').trim(), body: ''};
|
|
1938
|
+
}
|
|
1939
|
+
});
|
|
1940
|
+
if (cur) out.push(cur);
|
|
1941
|
+
// 完全没有编号:按空行切块,首行当标题
|
|
1942
|
+
if (out.length < 2 && out[0] && !out[0].body){
|
|
1943
|
+
const blocks = String(text || '').split(/\n\s*\n/).map(b=>b.trim()).filter(Boolean);
|
|
1944
|
+
if (blocks.length > 1){
|
|
1945
|
+
return blocks.map(b=>{
|
|
1946
|
+
const lines = b.split('\n').map(x=>x.trim()).filter(Boolean);
|
|
1947
|
+
return {title: lines[0].replace(/^[**]+|[**]+$/g, ''), body: lines.slice(1).join('\n')};
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
return out.filter(x=>x.title || x.body);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/* 纯提示词解析:剥 markdown 围栏,按 ---/=== 分隔行拆,再按空行拆;每段一条,标题/正文留空。
|
|
1955
|
+
主要场景:从别处复制来的现成提示词,不加工直接生图。 */
|
|
1956
|
+
function parsePrompts(text){
|
|
1957
|
+
let full = String(text || '').replace(/\r/g, '').trim();
|
|
1958
|
+
if (!full) return [];
|
|
1959
|
+
/* 整体被一个代码围栏包住:先剥壳 */
|
|
1960
|
+
const whole = full.match(/^```[^\n]*\n?([\s\S]*?)\n?```$/);
|
|
1961
|
+
if (whole) full = whole[1].trim();
|
|
1962
|
+
const out = [];
|
|
1963
|
+
full.split(/\n[ \t]*(?:-{3,}|={3,}|\*{3,}|~{3,})[ \t]*\n/)
|
|
1964
|
+
.flatMap(s => s.split(/\n\s*\n+/))
|
|
1965
|
+
.forEach(seg=>{
|
|
1966
|
+
const s0 = seg.trim();
|
|
1967
|
+
if (!s0) return;
|
|
1968
|
+
const fence = s0.match(/```[^\n]*\n([\s\S]*?)```/);
|
|
1969
|
+
const t = (fence ? fence[1] : s0).trim();
|
|
1970
|
+
if (t) out.push({title: '', body: '', prompt: t});
|
|
1971
|
+
});
|
|
1972
|
+
return out;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
/* 把解析结果放进清单:清单还是空的就直接铺开,否则追加 */
|
|
1976
|
+
function applyImport(parsed){
|
|
1977
|
+
if (!parsed.length) return 0;
|
|
1978
|
+
if (state.items.every(x=> !(x.title || '').trim() && !(x.body || '').trim())) state.items = [];
|
|
1979
|
+
const seen = new Set(state.items.map(x=> (x.title || '').trim()).filter(Boolean));
|
|
1980
|
+
let added = 0;
|
|
1981
|
+
parsed.forEach(x=>{
|
|
1982
|
+
const key = (x.title || '').trim();
|
|
1983
|
+
if (key && seen.has(key)) return; // 同一份清单别粘两遍
|
|
1984
|
+
if (key) seen.add(key);
|
|
1985
|
+
state.items.push({title: x.title, body: x.body, scene: '', prompt: x.prompt || '', cover: x.cover});
|
|
1986
|
+
added++;
|
|
1987
|
+
});
|
|
1988
|
+
renderItems();
|
|
1989
|
+
return added;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
let importMode = 'list'; /* list=按清单解析 | prompts=按提示词解析 */
|
|
1993
|
+
let importPh0 = null;
|
|
1994
|
+
|
|
1995
|
+
function openImport(mode){
|
|
1996
|
+
importMode = mode === 'prompts' ? 'prompts' : 'list';
|
|
1997
|
+
const prompts = importMode === 'prompts';
|
|
1998
|
+
if (importPh0 === null) importPh0 = $('#importText').getAttribute('placeholder');
|
|
1999
|
+
$('#importTitle').textContent = prompts ? '导入提示词' : '批量导入分镜';
|
|
2000
|
+
$('#importHint').innerHTML = prompts
|
|
2001
|
+
? '把别处复制来的现成提示词整段贴进来。<b>一段一条</b>,多条之间用空行或 <b>---</b> 分隔;markdown 代码围栏会自动剥掉。<br>导入后标题和正文保持空白,提示词原样用于出图,不会被重新拼装。'
|
|
2002
|
+
: '整段贴进来即可。每条以 <b>01|先存后花</b> 开头,下一行写正文,遇到下一个编号自动断开。识别出几张就生成几张图。<br>贴的是现成提示词?点工具条的「<b>导入提示词</b>」。';
|
|
2003
|
+
$('#importOk').textContent = prompts ? '导入提示词' : '导入';
|
|
2004
|
+
$('#importText').placeholder = prompts ? '把复制来的提示词整段贴进来,一段一条…' : importPh0;
|
|
2005
|
+
$('#importText').value = '';
|
|
2006
|
+
$('#importCount').textContent = '粘贴后自动识别';
|
|
2007
|
+
$('#importDlg').showModal();
|
|
2008
|
+
setTimeout(()=> $('#importText').focus(), 50);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/* 导入框边粘边报数:两种口径都报,用哪个按钮就按哪个算 */
|
|
2012
|
+
function countImport(){
|
|
2013
|
+
const txt = $('#importText').value;
|
|
2014
|
+
if (!txt.trim()){ $('#importCount').textContent = '粘贴后自动识别'; return; }
|
|
2015
|
+
const nl = parseImport(txt).length;
|
|
2016
|
+
const np = parsePrompts(txt).length;
|
|
2017
|
+
$('#importCount').textContent = '清单 ' + nl + ' 张 · 提示词 ' + np + ' 条';
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
async function copyText(text, okMsg){
|
|
2021
|
+
if (!text || !text.trim()){ toast('没有可复制的内容'); return; }
|
|
2022
|
+
try{ await navigator.clipboard.writeText(text); toast(okMsg || '已复制'); }
|
|
2023
|
+
catch{ toast('复制失败,请手动选中'); }
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function renderStrip(){
|
|
2027
|
+
const has = state.results.length > 0;
|
|
2028
|
+
$('#coverPrev').hidden = !has;
|
|
2029
|
+
$('#coverNext').hidden = !has;
|
|
2030
|
+
$('#coverNavRow').classList.toggle('show', has);
|
|
2031
|
+
$('#coverPrev').disabled = state.results.length < 2;
|
|
2032
|
+
$('#coverNext').disabled = state.results.length < 2;
|
|
2033
|
+
if (has) selectResult(state.idx);
|
|
2034
|
+
else $('#dlRow').classList.remove('show');
|
|
2035
|
+
saveSessionSoon(); // 图出来了也进会话快照,刷新不丢预览
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
/* 预览只加载压缩图;派生图万一挂了,自动退回原图,不白屏 */
|
|
2039
|
+
function setCover(prev, raw){
|
|
2040
|
+
const box = $('#cover'); box.innerHTML = '';
|
|
2041
|
+
const img = document.createElement('img');
|
|
2042
|
+
img.alt = '生成配图'; img.decoding = 'async';
|
|
2043
|
+
img.src = prev || raw;
|
|
2044
|
+
if (raw && prev !== raw){
|
|
2045
|
+
img.onerror = ()=>{ img.onerror = null; img.src = raw; };
|
|
2046
|
+
}
|
|
2047
|
+
box.appendChild(img);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/* 清空预览图,退回占位文案。载入一份还没出过图的任务、或者清空重来时用 */
|
|
2051
|
+
function clearCover(){
|
|
2052
|
+
const box = $('#cover'); box.innerHTML = '等待生成配图';
|
|
2053
|
+
$('#coverCount').textContent = '';
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
function selectResult(i){
|
|
2057
|
+
const n = state.results.length;
|
|
2058
|
+
if (!n) return;
|
|
2059
|
+
state.idx = Math.max(0, Math.min(n-1, i));
|
|
2060
|
+
const r = state.results[state.idx];
|
|
2061
|
+
setCover(r.preview || r.url, r.url);
|
|
2062
|
+
$('#coverCount').textContent = (state.idx + 1) + '/' + n;
|
|
2063
|
+
$('#dlRow').classList.add('show');
|
|
2064
|
+
const dl = $('#downloadBtn'); dl.href = downloadHref(r, r.url);
|
|
2065
|
+
$('#viewerMeta').textContent = `第 ${state.idx+1}/${n} 张 · ${r.topic}` + (r.account ? ' · ' + r.account : '');
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/* 大图查看器:同一个任务内部就能左右翻。
|
|
2069
|
+
kind='result' 看的是工作台本轮结果,kind='history' 看的是历史里某个任务的那一批图。
|
|
2070
|
+
以前两处都是「打开就锁死这一张」,想看同组下一张得关掉再点,翻八张点八次。 */
|
|
2071
|
+
const VIEW = { list: [], i: 0, kind: 'result' };
|
|
2072
|
+
|
|
2073
|
+
function viewItemAt(i){
|
|
2074
|
+
const n = VIEW.list.length;
|
|
2075
|
+
if (!n) return null;
|
|
2076
|
+
VIEW.i = ((i % n) + n) % n; // 首尾循环:最后一张再按「下一张」回到第一张
|
|
2077
|
+
return VIEW.list[VIEW.i];
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
/* 大图缩放:滚轮放大缩小,放大后可以拖着看。换图 / 关弹窗自动回 1 倍。
|
|
2081
|
+
只作用于 #viewImg,不动对话尺寸,翻页按钮永远在原位 */
|
|
2082
|
+
const ZOOM = {s: 1, x: 0, y: 0, min: 1, max: 6};
|
|
2083
|
+
|
|
2084
|
+
function clampPan(){
|
|
2085
|
+
const img = $('#viewImg');
|
|
2086
|
+
const mx = Math.max(0, (ZOOM.s - 1) * (img.clientWidth || 0) / 2);
|
|
2087
|
+
const my = Math.max(0, (ZOOM.s - 1) * (img.clientHeight || 0) / 2);
|
|
2088
|
+
ZOOM.x = Math.max(-mx, Math.min(mx, ZOOM.x));
|
|
2089
|
+
ZOOM.y = Math.max(-my, Math.min(my, ZOOM.y));
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
function applyZoom(){
|
|
2093
|
+
const img = $('#viewImg');
|
|
2094
|
+
if (!img) return;
|
|
2095
|
+
const on = ZOOM.s > 1.001;
|
|
2096
|
+
img.style.transform = on ? `translate(${ZOOM.x}px, ${ZOOM.y}px) scale(${ZOOM.s})` : '';
|
|
2097
|
+
const dlg = $('#viewDlg');
|
|
2098
|
+
if (dlg) dlg.classList.toggle('zoomed', on);
|
|
2099
|
+
const tag = $('#viewZoom');
|
|
2100
|
+
if (tag) tag.textContent = on ? '×' + ZOOM.s.toFixed(1) : '';
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function resetZoom(){
|
|
2104
|
+
ZOOM.s = 1; ZOOM.x = 0; ZOOM.y = 0;
|
|
2105
|
+
applyZoom();
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
function zoomAt(factor){
|
|
2109
|
+
const img = $('#viewImg');
|
|
2110
|
+
if (!img) return;
|
|
2111
|
+
const next = Math.max(ZOOM.min, Math.min(ZOOM.max, ZOOM.s * factor));
|
|
2112
|
+
if (Math.abs(next - ZOOM.s) < 0.001) return;
|
|
2113
|
+
// 平移量按比例跟着缩,画面不会突然跳走
|
|
2114
|
+
ZOOM.x *= next / ZOOM.s;
|
|
2115
|
+
ZOOM.y *= next / ZOOM.s;
|
|
2116
|
+
ZOOM.s = next;
|
|
2117
|
+
if (ZOOM.s <= 1.001){ ZOOM.x = 0; ZOOM.y = 0; }
|
|
2118
|
+
clampPan();
|
|
2119
|
+
applyZoom();
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
function renderView(i){
|
|
2123
|
+
const r = viewItemAt(i);
|
|
2124
|
+
if (!r) return;
|
|
2125
|
+
const n = VIEW.list.length;
|
|
2126
|
+
const hist = VIEW.kind === 'history';
|
|
2127
|
+
const src = hist ? r.image_url : (r.url || r.preview);
|
|
2128
|
+
const img = $('#viewImg');
|
|
2129
|
+
if (img.getAttribute('src') !== src){
|
|
2130
|
+
img.src = src; // 主动看大图才加载原图
|
|
2131
|
+
resetZoom(); // 换了一张,缩放归位
|
|
2132
|
+
}
|
|
2133
|
+
const pos = n > 1 ? `第 ${VIEW.i + 1}/${n} 张 · ` : '';
|
|
2134
|
+
$('#viewCap').textContent = hist
|
|
2135
|
+
? pos + (r.title || '未命名') + ' · ' + fmtTime(r.finished)
|
|
2136
|
+
: pos + (r.topic || '') + (r.account ? ' · ' + r.account : '');
|
|
2137
|
+
const vdl = $('#viewDl');
|
|
2138
|
+
const href = hist ? (r.download_url || r.image_url) : downloadHref(r, r.url);
|
|
2139
|
+
vdl.href = href || '#'; vdl.hidden = !href;
|
|
2140
|
+
$('#viewPrev').hidden = n < 2;
|
|
2141
|
+
$('#viewNext').hidden = n < 2;
|
|
2142
|
+
// 工作台侧的缩略条跟着走:关掉大图后停在你最后看的那张
|
|
2143
|
+
if (!hist){ state.idx = VIEW.i; selectResult(VIEW.i); }
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
function stepView(d){
|
|
2147
|
+
if (VIEW.list.length < 2) return;
|
|
2148
|
+
renderView(VIEW.i + d);
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function openViewer(){
|
|
2152
|
+
const list = state.results.filter(r=>r && (r.url || r.preview));
|
|
2153
|
+
if (!list.length){ toast('还没有可预览的图片'); return; }
|
|
2154
|
+
VIEW.kind = 'result';
|
|
2155
|
+
VIEW.list = list;
|
|
2156
|
+
VIEW.i = Math.max(0, list.indexOf(state.results[state.idx]));
|
|
2157
|
+
const dlg = $('#viewDlg');
|
|
2158
|
+
if (!dlg.open) dlg.showModal(); // 单击已经打开过一次时别重复 showModal(会抛异常)
|
|
2159
|
+
resetZoom();
|
|
2160
|
+
renderView(VIEW.i);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
/* 同一时间只允许一个批次在等图:手动点「批量出图」和刷新后的自动续接互斥 */
|
|
2164
|
+
let _batchRunning = false;
|
|
2165
|
+
let _stopRequested = false; // 点「停止生图」后置位,批量提交与轮询循环见到就退出
|
|
2166
|
+
|
|
2167
|
+
/* ---------- 空手点「批量出图」:先随机给一套示例,别让人猜图是按什么出的 ----------
|
|
2168
|
+
以前这里什么都不问,直接按当前组合拼一条没有标题没有正文的提示词出图,
|
|
2169
|
+
出来一张图里没有字的插画,谁也不知道它是按什么生成的。现在改成:
|
|
2170
|
+
只要用户什么都没提供,就先随机挑选题 + 组合,把分镜清单和提示词一起填好,
|
|
2171
|
+
再照常出图,并在配图工坊顶上挂一条说明这是示例(用户 2026-11-08 要求)。 */
|
|
2172
|
+
const DEMO_IDEAS = [
|
|
2173
|
+
{ topic: '秋天第一杯奶茶怎么点才不踩雷', title: '秋天的第一杯奶茶', points: [
|
|
2174
|
+
{ t: '先看糖度', b: '全糖差不多是半杯糖浆,从三分糖起步,喝着不齁还少一截热量。' },
|
|
2175
|
+
{ t: '配料别贪多', b: '珍珠、布丁、奶盖叠一块,口感打架还翻倍,留一样就够。' },
|
|
2176
|
+
{ t: '自带杯能省钱', b: '不少门店自带杯减三块,一年下来够再喝十几杯。' } ] },
|
|
2177
|
+
{ topic: '打工人工位抽屉里该放什么', title: '工位抽屉里的救命三件套', points: [
|
|
2178
|
+
{ t: '降噪耳塞', b: '隔壁组开始讨论需求的时候,塞上就能把注意力拽回来。' },
|
|
2179
|
+
{ t: '无糖薄荷糖', b: '下午三点最想摸零食,含一颗把嘴占住就过去了。' },
|
|
2180
|
+
{ t: '一个解压小玩意', b: '被消息追着跑的时候,捏两分钟比刷手机管用。' } ] },
|
|
2181
|
+
{ topic: '周末不社交怎么回血', title: '周末不社交也能回血', points: [
|
|
2182
|
+
{ t: '睡到自然醒', b: '别定闹钟,让身体自己决定几点起。' },
|
|
2183
|
+
{ t: '煮一锅汤', b: '洗菜切菜那四十分钟,比任何冥想都让人安静。' },
|
|
2184
|
+
{ t: '把手机放远', b: '丢到另一个房间,一小时后你会想起自己还会发呆。' } ] },
|
|
2185
|
+
{ topic: '第一次用 AI 写文案要注意什么', title: '第一次用 AI 写文案', points: [
|
|
2186
|
+
{ t: '先说清写给谁', b: '写给刚毕业的租房党,和写给二胎妈妈,是两套话。' },
|
|
2187
|
+
{ t: '给三个真细节', b: '越具体越不像 AI,比如凌晨一点半还在改第三版。' },
|
|
2188
|
+
{ t: '最后自己改一遍', b: '它给的句子删掉三分之一,剩下的才像人话。' } ] },
|
|
2189
|
+
];
|
|
2190
|
+
|
|
2191
|
+
function hideDemoNote(){ const el = $('#demoNote'); if (el) el.hidden = true; }
|
|
2192
|
+
|
|
2193
|
+
function showDemoNote(idea){
|
|
2194
|
+
const el = $('#demoNote'), tx = $('#demoNoteText');
|
|
2195
|
+
if (!el || !tx) return;
|
|
2196
|
+
const combo = [state.sel.layout && state.sel.layout.id, state.sel.style && state.sel.style.id,
|
|
2197
|
+
state.sel.color && state.sel.color.id].filter(Boolean).join(' / ') || '不限';
|
|
2198
|
+
tx.innerHTML = '<b>这是随机生成的示例</b>:你还没写选题和文案,系统随机挑了「' + idea.title
|
|
2199
|
+
+ '」和一组合(' + combo + '),分镜和提示词也是它自动拆的。'
|
|
2200
|
+
+ '在左边改文案、在清单里改分镜,再点批量出图,出来的就是你的内容。';
|
|
2201
|
+
el.hidden = false;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
/* 完全没提供任何东西:没有文案、没有分镜、没有手改指令、没有参考图 */
|
|
2205
|
+
function isBlankStart(){
|
|
2206
|
+
const hasItemText = state.items.some(it =>
|
|
2207
|
+
(it.title || '').trim() || (it.body || '').trim() || (it.prompt || '').trim());
|
|
2208
|
+
if (hasItemText) return false;
|
|
2209
|
+
if (['#topic', '#docTitle', '#docBody', '#note'].some(id=> ($(id).value || '').trim())) return false;
|
|
2210
|
+
if ((state.instrCustom || '').trim()) return false;
|
|
2211
|
+
return !state.ref;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
function fillDemoSample(){
|
|
2215
|
+
// 先立牌子再动内容:syncPicks / syncPreview 都会排队自动暂存,晚一步就会先存出一份示例草稿
|
|
2216
|
+
state.demoPending = true;
|
|
2217
|
+
const idea = DEMO_IDEAS[Math.floor(Math.random() * DEMO_IDEAS.length)];
|
|
2218
|
+
// 图型/风格/主题色一律补齐一套,别留空白:空着的话提示词里只能写「不限」,出的图没依据
|
|
2219
|
+
if (state.styles.length && state.layouts.length && state.colors.length){
|
|
2220
|
+
const r = a => a[Math.floor(Math.random() * a.length)];
|
|
2221
|
+
if (!state.selTouched){
|
|
2222
|
+
// 开场那套是程序填的默认值,不算用户选的:换一套随机的,示例才有示例的样子
|
|
2223
|
+
state.sel.style = r(state.styles);
|
|
2224
|
+
state.sel.layout = r(state.layouts);
|
|
2225
|
+
state.sel.color = r(state.colors);
|
|
2226
|
+
}else{
|
|
2227
|
+
// 用户亲手选过或亲手清过:只补空着的那几项,挑好的保留
|
|
2228
|
+
if (!state.sel.style) state.sel.style = r(state.styles);
|
|
2229
|
+
if (!state.sel.layout) state.sel.layout = r(state.layouts);
|
|
2230
|
+
if (!state.sel.color) state.sel.color = r(state.colors);
|
|
2231
|
+
}
|
|
2232
|
+
syncPicks();
|
|
2233
|
+
}
|
|
2234
|
+
$('#topic').value = idea.topic;
|
|
2235
|
+
$('#docTitle').value = idea.title;
|
|
2236
|
+
$('#docBody').value = idea.points
|
|
2237
|
+
.map((p, i)=> String(i + 1).padStart(2, '0') + '|' + p.t + '\n' + p.b).join('\n\n');
|
|
2238
|
+
syncPreview();
|
|
2239
|
+
// 封面 + 三条,和「拆成分镜」拆出来的形状一致
|
|
2240
|
+
state.items = [{title: idea.title, body: '', scene: '', prompt: '', cover: true}]
|
|
2241
|
+
.concat(idea.points.map((p, i)=>({
|
|
2242
|
+
title: String(i + 1).padStart(2, '0') + ' ' + p.t,
|
|
2243
|
+
body: p.b, scene: '', prompt: ''
|
|
2244
|
+
})));
|
|
2245
|
+
state.group = idea.title;
|
|
2246
|
+
$('#groupTag').textContent = '整组主题:' + idea.title;
|
|
2247
|
+
renderItems();
|
|
2248
|
+
showDemoNote(idea);
|
|
2249
|
+
noteContentChanged();
|
|
2250
|
+
return idea;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
async function runImage(){
|
|
2254
|
+
if (!redoGuard()) return;
|
|
2255
|
+
if (_batchRunning){ toast('上一批还在出图,等它跑完再点'); return; }
|
|
2256
|
+
_stopRequested = false;
|
|
2257
|
+
// 什么都没有就直接点:先补一套随机示例,否则出的图没有依据
|
|
2258
|
+
const demoIdea = isBlankStart() ? fillDemoSample() : null;
|
|
2259
|
+
let items = state.items.map(it=>({...it}));
|
|
2260
|
+
if (!items.length){
|
|
2261
|
+
const t = $('#topic').value.trim();
|
|
2262
|
+
if (t) items = [{title: t, body: '', prompt: ''}];
|
|
2263
|
+
}
|
|
2264
|
+
// 全空也放行:没有标题/正文/选题时,按当前选型 + 出图指令直接出一版提示词
|
|
2265
|
+
if (!items.length) items = [{title: '', body: '', prompt: ''}];
|
|
2266
|
+
// 清单是空的就补一条占位卡,让人看得见这一张是按什么拼出来的
|
|
2267
|
+
if (!state.items.length){
|
|
2268
|
+
state.items = items.map(x=>({...x}));
|
|
2269
|
+
renderItems();
|
|
2270
|
+
}
|
|
2271
|
+
const statusEl = $('#imageStatus');
|
|
2272
|
+
const btn = $('#genImage');
|
|
2273
|
+
btn.disabled = true;
|
|
2274
|
+
_batchRunning = true;
|
|
2275
|
+
const _sg = $('#stopGen'); if (_sg) _sg.disabled = false;
|
|
2276
|
+
const _ms = $('#monStop'); if (_ms) _ms.disabled = false;
|
|
2277
|
+
state.results = []; state.idx = 0;
|
|
2278
|
+
// 一次批量出图 = 一个任务,历史里按它折叠成一组
|
|
2279
|
+
const taskId = 'k' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
2280
|
+
statusEl.className = 'status run';
|
|
2281
|
+
statusEl.textContent = demoIdea ? '已随机生成示例,准备提示词…' : '准备提示词…';
|
|
2282
|
+
if (demoIdea) toast('没写内容,先给一套随机示例:' + demoIdea.title);
|
|
2283
|
+
renderStrip();
|
|
2284
|
+
try{
|
|
2285
|
+
// 缺提示词的先拼出来;已经手改过的保留手改版本
|
|
2286
|
+
const missing = items.filter(x=>!x.prompt || !x.prompt.trim());
|
|
2287
|
+
if (missing.length){
|
|
2288
|
+
statusEl.textContent = '拼装提示词…';
|
|
2289
|
+
const d = await postCompose(missing);
|
|
2290
|
+
missing.forEach((x, k)=>{
|
|
2291
|
+
x.prompt = leadFor(x) + d.items[k].prompt;
|
|
2292
|
+
});
|
|
2293
|
+
// 拼好的提示词写回清单:以前只留在这份临时副本里,分镜卡的提示词框一直是空的,
|
|
2294
|
+
// 用户看不到「随机生成/自动拼」到底写了什么,也没法就地改
|
|
2295
|
+
if (items.length === state.items.length){
|
|
2296
|
+
items.forEach((x, k)=>{ if (state.items[k]) state.items[k].prompt = x.prompt; });
|
|
2297
|
+
renderItems();
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
const ids = [];
|
|
2301
|
+
const topics = items.map(it=>it.title || it.body || '配图');
|
|
2302
|
+
// 整篇正文一起落进历史,下次「载入」才还原得回来
|
|
2303
|
+
const docBody = $('#docBody').value;
|
|
2304
|
+
// 并行出图完成顺序是乱的,seq 记下提交顺序,历史里才能还原成 01 02 03
|
|
2305
|
+
const redo = state.redo;
|
|
2306
|
+
let seq = 0;
|
|
2307
|
+
for (const it of items){
|
|
2308
|
+
if (_stopRequested) throw new Error('已停止生图');
|
|
2309
|
+
const topic = (it.title || it.body || '配图').slice(0, 60);
|
|
2310
|
+
const prompt = await redoPrompt(it, redo, it.prompt);
|
|
2311
|
+
const {job_id} = await submitGenerate({...payload('image', topic), task_id: taskId, seq: seq++,
|
|
2312
|
+
doc_body: docBody, title: it.title, body: it.body, prompt,
|
|
2313
|
+
...redoExtra(redo)});
|
|
2314
|
+
ids.push(job_id);
|
|
2315
|
+
// 每提交一张就刷一次断点:刷新页面后能凭它接着等图
|
|
2316
|
+
saveBatch({task: taskId, ids: ids.slice(), topics, t: Date.now()});
|
|
2317
|
+
statusEl.textContent = `已提交 ${ids.length}/${items.length}…`;
|
|
2318
|
+
}
|
|
2319
|
+
// 整批都提交成功=这份草稿的活干完了,从草稿箱移走(内容和提示词已随这一批进历史)
|
|
2320
|
+
const consumed = await consumeCurrentDraft();
|
|
2321
|
+
if (consumed) toast('已开始出图,这份草稿从草稿箱移出');
|
|
2322
|
+
await pollBatch(ids, topics, statusEl);
|
|
2323
|
+
if (state.results.length) clearRedo(true);
|
|
2324
|
+
}catch(e){
|
|
2325
|
+
statusEl.className = _stopRequested ? 'status' : 'status err';
|
|
2326
|
+
statusEl.textContent = _stopRequested ? '已停止生图' : e.message;
|
|
2327
|
+
if (_stopRequested){
|
|
2328
|
+
setTimeout(()=>{ if (statusEl.textContent === '已停止生图') statusEl.textContent = ''; }, 20000);
|
|
2329
|
+
} else {
|
|
2330
|
+
toast(e.message, 'err');
|
|
2331
|
+
}
|
|
2332
|
+
}finally{
|
|
2333
|
+
btn.disabled = false;
|
|
2334
|
+
_batchRunning = false;
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
async function pollBatch(ids, topics, statusEl){
|
|
2339
|
+
const t0 = Date.now();
|
|
2340
|
+
const pending = new Set(ids.map((_,i)=>i));
|
|
2341
|
+
let finished = 0, failed = 0, cancelled = 0, lastErr = '';
|
|
2342
|
+
const retrying = new Set();
|
|
2343
|
+
const retryCount = new Map(); // 第 N 张已经换到第几轮
|
|
2344
|
+
const failAcc = new Set(); // 这批里失败过的账号,汇总时如实点名
|
|
2345
|
+
let swappedOk = 0; // 换过号但最后成功了的张数
|
|
2346
|
+
const gone = new Map(); // 连续查不到这个 job 的次数:后端重启且任务没落盘时才会发生
|
|
2347
|
+
// 失败会自动重试(20s + 60s 两档),预算按「基础 6 分钟 + 重试 5 分钟」给
|
|
2348
|
+
const maxTicks = 300 * ids.length;
|
|
2349
|
+
for (let tick=0; tick<maxTicks && pending.size; tick++){
|
|
2350
|
+
if (_stopRequested){
|
|
2351
|
+
statusEl.className = 'status'; statusEl.textContent = '已停止生图';
|
|
2352
|
+
setTimeout(()=>{ if (statusEl.textContent === '已停止生图') statusEl.textContent = ''; }, 20000);
|
|
2353
|
+
clearBatch();
|
|
2354
|
+
return;
|
|
2355
|
+
}
|
|
2356
|
+
await new Promise(r=>setTimeout(r, 2000));
|
|
2357
|
+
let runningAccount = '';
|
|
2358
|
+
for (const i of [...pending]){
|
|
2359
|
+
let d;
|
|
2360
|
+
try {
|
|
2361
|
+
const rr = await fetch('/api/jobs/' + ids[i]);
|
|
2362
|
+
if (rr.status === 404) throw new Error('404');
|
|
2363
|
+
d = await rr.json();
|
|
2364
|
+
} catch {
|
|
2365
|
+
// 查不到就当一张废票:连续 5 次(约 10 秒)才判定丢失,避免后端重启瞬间误杀
|
|
2366
|
+
const n = (gone.get(i) || 0) + 1;
|
|
2367
|
+
gone.set(i, n);
|
|
2368
|
+
if (n >= 5){
|
|
2369
|
+
pending.delete(i); failed++;
|
|
2370
|
+
lastErr = '任务已丢失(后端重启且未能恢复),请重新出这一张';
|
|
2371
|
+
toast('后端重启且这张没恢复,需要重新出这一张', {type: 'err', title: `第 ${i + 1} 张已丢失`});
|
|
2372
|
+
}
|
|
2373
|
+
continue;
|
|
2374
|
+
}
|
|
2375
|
+
gone.delete(i);
|
|
2376
|
+
if (d.status === 'running'){ runningAccount = d.account || ''; continue; }
|
|
2377
|
+
if (d.status === 'cancelled'){
|
|
2378
|
+
// 手动停止不是失败:单独计数,汇总文案里说「未出」而不是「失败」
|
|
2379
|
+
pending.delete(i); cancelled++; lastErr = '已手动停止出图';
|
|
2380
|
+
continue;
|
|
2381
|
+
}
|
|
2382
|
+
if (d.status !== 'done' && d.status !== 'error'){
|
|
2383
|
+
// 失败后后端会换号重试(15s / 40s / 90s 三档),这期间状态是 queued,
|
|
2384
|
+
// 不能当失败处理,否则任务就断在这一张上了
|
|
2385
|
+
if (d.status === 'queued' && d.auto_retry){
|
|
2386
|
+
retrying.add(i + 1);
|
|
2387
|
+
retryCount.set(i + 1, d.auto_retry);
|
|
2388
|
+
}
|
|
2389
|
+
continue;
|
|
2390
|
+
}
|
|
2391
|
+
retrying.delete(i + 1);
|
|
2392
|
+
retryCount.delete(i + 1);
|
|
2393
|
+
pending.delete(i);
|
|
2394
|
+
if (d.status === 'done'){
|
|
2395
|
+
finished++;
|
|
2396
|
+
const raw = d.image_url;
|
|
2397
|
+
const prev = d.preview_url || ('/api/history/' + ids[i] + '/preview');
|
|
2398
|
+
const rec = {url: raw, preview: prev, topic: topics[i], seq: i, id: ids[i], account: d.account || '',
|
|
2399
|
+
w: d.result && d.result.width, h: d.result && d.result.height};
|
|
2400
|
+
state.results.push(rec);
|
|
2401
|
+
// 并行出图完成顺序是乱的,展示一律按清单顺序(seq)排
|
|
2402
|
+
state.results.sort((a,b)=>(a.seq??0)-(b.seq??0));
|
|
2403
|
+
state.idx = state.results.indexOf(rec);
|
|
2404
|
+
renderStrip();
|
|
2405
|
+
const swapped = (d.attempts || []).length;
|
|
2406
|
+
if (swapped) swappedOk++;
|
|
2407
|
+
toast(topics[i] + (d.account ? ' · ' + d.account : '')
|
|
2408
|
+
+ (swapped ? ` · 换号 ${swapped} 次后成功` : ''),
|
|
2409
|
+
{type: 'info', title: `第 ${finished}/${ids.length} 张完成`});
|
|
2410
|
+
} else {
|
|
2411
|
+
failed++; lastErr = d.error || '未知错误';
|
|
2412
|
+
// 后端把每次尝试用的账号都记在 attempts 里,这里如实列出是谁失败的
|
|
2413
|
+
const att = d.attempts || [];
|
|
2414
|
+
const who = [...new Set(att.map(a => a.account).filter(Boolean))];
|
|
2415
|
+
who.forEach(n => failAcc.add(n));
|
|
2416
|
+
const detail = who.length ? `本次换过 ${who.length} 个号:${who.join('、')}` : '未能定位到具体账号';
|
|
2417
|
+
toast(detail, {type: 'err', title: `第 ${i + 1} 张最终失败`, ms: 6500});
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
if (pending.size){
|
|
2421
|
+
statusEl.className = 'status run';
|
|
2422
|
+
const nRun = [...pending].length;
|
|
2423
|
+
const rt = [...retrying].sort((a,b)=>a-b)
|
|
2424
|
+
.map(n => `第 ${n} 张换号重试中 ${retryCount.get(n) || 1}/3`);
|
|
2425
|
+
statusEl.textContent = `完成 ${finished}/${ids.length}` +
|
|
2426
|
+
(runningAccount ? ` · 并行生成中 ${runningAccount}` : ` · 剩余 ${nRun} 张排队…`) +
|
|
2427
|
+
(rt.length ? ` · ${rt.join(',')}` : '');
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
if (cancelled){
|
|
2431
|
+
// 手动停止:中性提示,不算失败;20 秒后自动收掉,免得一行字一直挂着
|
|
2432
|
+
statusEl.className = 'status';
|
|
2433
|
+
const txt = `已停止 · 完成 ${finished}/${ids.length} 张` +
|
|
2434
|
+
(cancelled ? `,${cancelled} 张未出` : '') +
|
|
2435
|
+
(failed ? `,${failed} 张失败` : '');
|
|
2436
|
+
statusEl.textContent = txt;
|
|
2437
|
+
setTimeout(()=>{ if (statusEl.textContent === txt) statusEl.textContent = ''; }, 20000);
|
|
2438
|
+
} else {
|
|
2439
|
+
statusEl.className = failed ? 'status err' : 'status ok';
|
|
2440
|
+
statusEl.textContent = `完成 ${finished}/${ids.length} 张` +
|
|
2441
|
+
(failed ? ` · 失败 ${failed}(${lastErr})` : '') +
|
|
2442
|
+
(pending.size ? ` · ${pending.size} 张超时` : '');
|
|
2443
|
+
}
|
|
2444
|
+
if (!pending.size) clearBatch();
|
|
2445
|
+
// 一整批跑完单独给一次总气泡:中间每张只是轻提示,容易被下一条盖掉
|
|
2446
|
+
if (!state.results.length){
|
|
2447
|
+
// 手动停止时不该弹「出图失败」,那是自己关的
|
|
2448
|
+
if (!(cancelled && !failed)){
|
|
2449
|
+
toast(failAcc.size ? `失败账号:${[...failAcc].join('、')}` : '一张都没出来,检查号池状态或稍后重试',
|
|
2450
|
+
{type: 'err', title: '本轮出图失败'});
|
|
2451
|
+
}
|
|
2452
|
+
} else {
|
|
2453
|
+
const allOk = failed === 0 && !pending.size;
|
|
2454
|
+
const swapNote = swappedOk ? ` · ${swappedOk} 张换号后出图` : '';
|
|
2455
|
+
const tail = allOk ? ' · 点气泡看大图' + swapNote
|
|
2456
|
+
: swapNote + (failed ? ` · ${failed} 张失败` : '') +
|
|
2457
|
+
(failAcc.size ? `(${[...failAcc].join('、')})` : '') +
|
|
2458
|
+
(pending.size ? ` · ${pending.size} 张超时` : '');
|
|
2459
|
+
toast(`${finished}/${ids.length} 张 · 用时 ${fmtDur(Date.now() - t0)}${tail}`,
|
|
2460
|
+
{type: allOk ? 'ok' : 'err',
|
|
2461
|
+
title: allOk ? '本轮出图全部完成' : '本轮出图结束,有没成的',
|
|
2462
|
+
click: ()=>{ if (state.results.length) openViewer(); }});
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
/* ---------- 批量断点:刷新/关页面后回来接着等图 ---------- */
|
|
2467
|
+
function saveBatch(b){
|
|
2468
|
+
try{ localStorage.setItem('wb_batch', JSON.stringify(b)); }catch(e){}
|
|
2469
|
+
}
|
|
2470
|
+
function loadBatch(){
|
|
2471
|
+
try{ return JSON.parse(localStorage.getItem('wb_batch') || 'null'); }catch(e){ return null; }
|
|
2472
|
+
}
|
|
2473
|
+
function clearBatch(){
|
|
2474
|
+
try{ localStorage.removeItem('wb_batch'); }catch(e){}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
async function resumeBatch(){
|
|
2478
|
+
const b = loadBatch();
|
|
2479
|
+
if (!b || !Array.isArray(b.ids) || !b.ids.length) return;
|
|
2480
|
+
// 超过 2 小时的批次不自动接:图早进历史了,硬接只会白等
|
|
2481
|
+
if (Date.now() - (b.t || 0) > 2 * 3600 * 1000){ clearBatch(); return; }
|
|
2482
|
+
if (_batchRunning) return;
|
|
2483
|
+
_stopRequested = false;
|
|
2484
|
+
_batchRunning = true;
|
|
2485
|
+
const statusEl = $('#imageStatus');
|
|
2486
|
+
const btn = $('#genImage');
|
|
2487
|
+
btn.disabled = true;
|
|
2488
|
+
statusEl.className = 'status run';
|
|
2489
|
+
statusEl.textContent = `接上上次没跑完的 ${b.ids.length} 张…`;
|
|
2490
|
+
toast(`检测到上次未完成的 ${b.ids.length} 张,自动接着等`);
|
|
2491
|
+
try{
|
|
2492
|
+
await pollBatch(b.ids, b.topics || b.ids.map(()=> '配图'), statusEl);
|
|
2493
|
+
}catch(e){
|
|
2494
|
+
statusEl.className = 'status err'; statusEl.textContent = e.message;
|
|
2495
|
+
}finally{
|
|
2496
|
+
_batchRunning = false;
|
|
2497
|
+
btn.disabled = false;
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
async function refreshPool(){
|
|
2502
|
+
try{
|
|
2503
|
+
const d = await (await fetch('/api/pool')).json();
|
|
2504
|
+
const mode = (d.pool && d.pool.mode) || (d.accounts.some(a=>a.env_id) ? 'pool' : 'single');
|
|
2505
|
+
const run = (d.running || []).length;
|
|
2506
|
+
const _sg2 = $('#stopGen');
|
|
2507
|
+
if (_sg2) _sg2.disabled = !(run || d.queued || _batchRunning);
|
|
2508
|
+
if (mode === 'single'){
|
|
2509
|
+
const online = !!(d.pool && d.pool.online);
|
|
2510
|
+
const name = (d.pool && d.pool.name) || '接口';
|
|
2511
|
+
$('#poolText').textContent = (online ? '接口在线' : '接口离线') + ` · ${name}`
|
|
2512
|
+
+ (run ? ` · 并行 ${run}/${d.workers || 1}` : '');
|
|
2513
|
+
$('#poolDot').className = 'dot' + (online ? '' : ' off');
|
|
2514
|
+
} else {
|
|
2515
|
+
const pool = d.pool || {};
|
|
2516
|
+
const hasAcc = d.accounts.length > 0;
|
|
2517
|
+
// 明细拉不到时(换接口后 admin-key 对不上)退回号池自报的数字,别显示成 0/0
|
|
2518
|
+
const ready = hasAcc ? d.accounts.filter(a=>a.status==='ready' && a.env_id).length
|
|
2519
|
+
: Number(pool.active || 0);
|
|
2520
|
+
const total = hasAcc ? d.accounts.filter(a=>a.env_id).length
|
|
2521
|
+
: Number(pool.total || 0);
|
|
2522
|
+
$('#poolText').textContent = `号池 ${ready}/${total} 可用`
|
|
2523
|
+
+ (run ? ` · 并行 ${run}/${d.workers || 1}` : '')
|
|
2524
|
+
+ ((d.queued || 0) ? ` · 排队 ${d.queued}` : '');
|
|
2525
|
+
$('#poolDot').className = 'dot' + (ready ? '' : ' off');
|
|
2526
|
+
if (hasAcc){
|
|
2527
|
+
const bad = d.accounts.filter(a=>a.status==='unhealthy').map(a=>a.masked);
|
|
2528
|
+
const parts = [];
|
|
2529
|
+
if (bad.length) parts.push(`下线:${bad.join('、')}`);
|
|
2530
|
+
if (d.fails_today) parts.push(`今日失败 ${d.fails_today} 次(已换号重试)`);
|
|
2531
|
+
$('#poolDetail').textContent = parts.length ? parts.join(' · ') : '账号全部在线';
|
|
2532
|
+
} else {
|
|
2533
|
+
$('#poolDetail').textContent = d.accounts_error || '账号明细加载中…';
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
}catch(e){
|
|
2537
|
+
$('#poolText').textContent = '接口连不上'; $('#poolDot').className = 'dot off';
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
/* ---------- 接口设置 ---------- */
|
|
2542
|
+
/* 工作台名字:页头标题和浏览器标签共用一个名,存 config 的 app_name */
|
|
2543
|
+
const APP_NAME_DEFAULT = 'Bluxe手绘工坊 · 生图工作台';
|
|
2544
|
+
function applyAppName(name){
|
|
2545
|
+
const n = (name || '').trim() || APP_NAME_DEFAULT;
|
|
2546
|
+
state.appName = n;
|
|
2547
|
+
const el = $('#appName'); if (el) el.textContent = n;
|
|
2548
|
+
document.title = n;
|
|
2549
|
+
}
|
|
2550
|
+
async function loadAppName(){
|
|
2551
|
+
try{
|
|
2552
|
+
const d = await (await fetch('/api/config')).json();
|
|
2553
|
+
applyAppName(d.app_name || d.app_name_default);
|
|
2554
|
+
}catch(e){ /* 读不到就用默认名,不影响其他功能 */ }
|
|
2555
|
+
}
|
|
2556
|
+
function cfgFill(d){
|
|
2557
|
+
// 版本号放在标题旁:反馈问题时截图就能看出装的是哪一版
|
|
2558
|
+
const v = $('#cfgVersion'); if (v) v.textContent = d.version ? ('v' + d.version) : '';
|
|
2559
|
+
$('#cfgAppName').value = d.app_name || d.app_name_default || '';
|
|
2560
|
+
$('#cfgBase').value = d.base_url || '';
|
|
2561
|
+
$('#cfgKey').value = '';
|
|
2562
|
+
$('#cfgKey').placeholder = d.api_key_set ? '留空沿用已保存密钥' : 'sk-...(未配置)';
|
|
2563
|
+
$('#cfgKeyHint').textContent = d.api_key_set ? ('当前:' + (d.api_key_masked || '已配置')) : '尚未配置密钥';
|
|
2564
|
+
$('#cfgModelImage').value = d.model_image || '';
|
|
2565
|
+
$('#cfgModelText').value = d.model_text || '';
|
|
2566
|
+
// 默认目录不回填到输入框,留空表示「用默认」,占位符里给出真实路径
|
|
2567
|
+
const def = d.output_dir_default || '';
|
|
2568
|
+
$('#cfgOutDir').value = d.output_dir_is_default ? '' : (d.output_dir || '');
|
|
2569
|
+
$('#cfgOutDir').placeholder = def ? '默认:' + def : '留空 = 工作台默认目录';
|
|
2570
|
+
$('#cfgOutHint').textContent = d.output_dir_is_default
|
|
2571
|
+
? '当前存到默认目录;改成别的绝对路径(例如 D:\\DH_Picture)后,新图会存到那里,旧图仍可正常打开。'
|
|
2572
|
+
: '当前:' + (d.output_dir || '') + '(留空保存即回到默认目录)';
|
|
2573
|
+
$('#cfgEdits').checked = !!d.supports_edits;
|
|
2574
|
+
$('#cfgFormat').value = d.image_response_format || 'auto';
|
|
2575
|
+
$('#cfgEditsStyle').value = d.edits_style || 'auto';
|
|
2576
|
+
const modeTxt = (d.mode === 'pool' ? '号池模式' : '通用接口模式')
|
|
2577
|
+
+ (d.pool_message ? ' · ' + d.pool_message : '');
|
|
2578
|
+
$('#cfgEngine').textContent = modeTxt;
|
|
2579
|
+
$('#cfgAppNameHint').textContent = '默认名:' + (d.app_name_default || APP_NAME_DEFAULT) + '(留空保存即回到默认名)';
|
|
2580
|
+
}
|
|
2581
|
+
function cfgPayload(){
|
|
2582
|
+
return {
|
|
2583
|
+
app_name: $('#cfgAppName').value.trim(),
|
|
2584
|
+
base_url: $('#cfgBase').value.trim(),
|
|
2585
|
+
output_dir: $('#cfgOutDir').value.trim(),
|
|
2586
|
+
api_key: $('#cfgKey').value.trim(),
|
|
2587
|
+
model_image: $('#cfgModelImage').value.trim(),
|
|
2588
|
+
model_text: $('#cfgModelText').value.trim(),
|
|
2589
|
+
supports_edits: $('#cfgEdits').checked,
|
|
2590
|
+
edits_style: $('#cfgEditsStyle').value,
|
|
2591
|
+
image_response_format: $('#cfgFormat').value,
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
async function openConfig(){
|
|
2595
|
+
$('#configDlg').showModal();
|
|
2596
|
+
$('#cfgResult').textContent = '';
|
|
2597
|
+
showCfgTab('iface');
|
|
2598
|
+
try{
|
|
2599
|
+
const d = await (await fetch('/api/config')).json();
|
|
2600
|
+
cfgFill(d);
|
|
2601
|
+
}catch(e){ $('#cfgResult').textContent = '读取配置失败:' + e.message; }
|
|
2602
|
+
loadPoolPanel(true); // 顺手把「本地号池」页签上的可用数填上,不用点进去等
|
|
2603
|
+
}
|
|
2604
|
+
async function testConn(){
|
|
2605
|
+
const r = $('#cfgResult'); r.style.color = 'var(--ink-3)'; r.textContent = '测试中…';
|
|
2606
|
+
try{
|
|
2607
|
+
const d = await (await fetch('/api/config/test', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(cfgPayload())})).json();
|
|
2608
|
+
r.style.color = d.ok ? '#1f9d6b' : '#d4380d';
|
|
2609
|
+
r.textContent = (d.ok ? '✓ ' : '✗ ') + d.message
|
|
2610
|
+
+ (d.models && d.models.length ? '(例:' + d.models.slice(0,3).join('、') + '…)' : '');
|
|
2611
|
+
}catch(e){ r.style.color = '#d4380d'; r.textContent = '测试请求失败:' + e.message; }
|
|
2612
|
+
}
|
|
2613
|
+
async function saveConfig(){
|
|
2614
|
+
const r = $('#cfgResult'); r.style.color = 'var(--ink-3)'; r.textContent = '保存中…';
|
|
2615
|
+
// 名字先换上去:保存要顺带重探号池,慢的时候别让人对着旧标题等
|
|
2616
|
+
const wantName = $('#cfgAppName').value.trim();
|
|
2617
|
+
applyAppName(wantName);
|
|
2618
|
+
try{
|
|
2619
|
+
const resp = await fetch('/api/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(cfgPayload())});
|
|
2620
|
+
const d = await resp.json();
|
|
2621
|
+
if (!resp.ok) throw new Error(d.detail || ('HTTP ' + resp.status));
|
|
2622
|
+
cfgFill(d);
|
|
2623
|
+
applyAppName(d.app_name);
|
|
2624
|
+
r.style.color = '#1f9d6b';
|
|
2625
|
+
r.textContent = '已保存:' + d.app_name + ' · ' + (d.mode === 'pool' ? '号池模式' : '通用接口模式') + ' · ' + d.base_url;
|
|
2626
|
+
toast('设置已保存');
|
|
2627
|
+
refreshPool();
|
|
2628
|
+
}catch(e){
|
|
2629
|
+
r.style.color = '#d4380d'; r.textContent = '保存失败:' + e.message;
|
|
2630
|
+
loadAppName(); // 没存上就把标题退回服务端的真实值
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
/* ---------- 本地号池:状态 / 进程控制 / 导入账号 / 对外接口 ---------- */
|
|
2635
|
+
let poolPanel = null;
|
|
2636
|
+
let poolBusy = false;
|
|
2637
|
+
function showCfgTab(name){
|
|
2638
|
+
document.querySelectorAll('.cfg-tab').forEach(b => b.classList.toggle('on', b.dataset.tab === name));
|
|
2639
|
+
$('#tabIface').hidden = name !== 'iface';
|
|
2640
|
+
$('#tabPool').hidden = name !== 'pool';
|
|
2641
|
+
$('#cfgFoot').hidden = name !== 'iface';
|
|
2642
|
+
if (name === 'pool') loadPoolPanel();
|
|
2643
|
+
}
|
|
2644
|
+
document.querySelectorAll('.cfg-tab').forEach(b => { b.onclick = ()=> showCfgTab(b.dataset.tab); });
|
|
2645
|
+
|
|
2646
|
+
function poolAccRow(a){
|
|
2647
|
+
const row = document.createElement('div'); row.className = 'pacc-row';
|
|
2648
|
+
const mail = document.createElement('span'); mail.className = 'pacc-mail';
|
|
2649
|
+
mail.textContent = a.email || '(无邮箱)';
|
|
2650
|
+
if (a.error) mail.title = '最近一次报错:' + a.error;
|
|
2651
|
+
const chip = document.createElement('span');
|
|
2652
|
+
chip.className = 'pchip ' + (a.cls || 'warn'); chip.textContent = a.status || '正常';
|
|
2653
|
+
const q = document.createElement('span'); q.className = 'pacc-num';
|
|
2654
|
+
q.textContent = a.quota > 0 ? ('剩 ' + a.quota) : '已用完';
|
|
2655
|
+
if (a.quota <= 0) q.style.color = 'var(--warn)';
|
|
2656
|
+
const u = document.createElement('span'); u.className = 'pacc-use';
|
|
2657
|
+
u.textContent = `用 ${a.success} / 错 ${a.fail}`;
|
|
2658
|
+
const act = document.createElement('span'); act.className = 'pacc-act';
|
|
2659
|
+
const tog = document.createElement('button');
|
|
2660
|
+
tog.type = 'button'; tog.textContent = a.status === '禁用' ? '启用' : '停用';
|
|
2661
|
+
tog.onclick = ()=> poolToggle(a);
|
|
2662
|
+
const del = document.createElement('button');
|
|
2663
|
+
del.type = 'button'; del.className = 'danger'; del.textContent = '删除';
|
|
2664
|
+
del.onclick = ()=> poolDelete(a);
|
|
2665
|
+
act.appendChild(tog); act.appendChild(del);
|
|
2666
|
+
row.appendChild(mail); row.appendChild(chip); row.appendChild(q); row.appendChild(u); row.appendChild(act);
|
|
2667
|
+
return row;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
function renderPool(d){
|
|
2671
|
+
poolPanel = d;
|
|
2672
|
+
const on = !!d.online;
|
|
2673
|
+
const sv = d.service || {};
|
|
2674
|
+
$('#pDot').className = 'dot' + (on ? '' : ' off');
|
|
2675
|
+
$('#pState').textContent = on ? '号池在线' : (d.local ? '号池没在跑' : '当前不是本机号池');
|
|
2676
|
+
// 号池归本工作台管:停了就地启动,不用再去别的插件
|
|
2677
|
+
const canManage = !!sv.local && !!sv.manageable;
|
|
2678
|
+
$('#pStart').hidden = !canManage || on || !!poolBusy;
|
|
2679
|
+
$('#pRestart').hidden = !canManage || !sv.running || !!poolBusy;
|
|
2680
|
+
$('#pStop').hidden = !canManage || !sv.running || !!poolBusy;
|
|
2681
|
+
// 接口不在内置池(比如改指了 38650)时给一个回程入口;已在内置池就绝不显示。
|
|
2682
|
+
// 不能用 d.local 判断:38650 同样跑在本机回环,local 一样为 true,只认端口。
|
|
2683
|
+
const useBuf = $('#pUseBuiltin');
|
|
2684
|
+
if (useBuf) useBuf.hidden = /:38651(\D|$)/.test(d.base_url || '') || !!poolBusy;
|
|
2685
|
+
// 上一步动作把按钮 disable 过,这里必须按最新状态复位,否则「启动」看得到却点不动
|
|
2686
|
+
['pStart', 'pRestart', 'pStop'].forEach(id => { $('#' + id).disabled = !!poolBusy; });
|
|
2687
|
+
$('#pAuto').disabled = !canManage;
|
|
2688
|
+
$('#pAuto').checked = !!sv.auto_start;
|
|
2689
|
+
$('#pSvcMsg').textContent = sv.message || (on ? '工作台守护中' : '');
|
|
2690
|
+
$('#pSvcMsg').title = sv.log || '';
|
|
2691
|
+
const bits = [];
|
|
2692
|
+
if (d.local && on){
|
|
2693
|
+
bits.push(`${d.total} 个账号`, `可用 ${d.active}`);
|
|
2694
|
+
if (d.limited) bits.push(`限流 ${d.limited}`);
|
|
2695
|
+
if (d.disabled) bits.push(`停用 ${d.disabled}`);
|
|
2696
|
+
bits.push(`剩 ${d.quota} 次额度`);
|
|
2697
|
+
} else {
|
|
2698
|
+
bits.push(d.message || '连接失败');
|
|
2699
|
+
}
|
|
2700
|
+
$('#pMeta').textContent = bits.filter(Boolean).join(' · ');
|
|
2701
|
+
$('#poolTabTag').textContent = (d.local && on) ? `${d.active}/${d.total}` : '';
|
|
2702
|
+
|
|
2703
|
+
$('#pBase').textContent = d.base_url || '—';
|
|
2704
|
+
$('#pBase').title = d.base_url || '';
|
|
2705
|
+
$('#pKey').textContent = d.api_key || (d.local ? '还没有用户 Key,点「重新生成」' : '—');
|
|
2706
|
+
$('#pKey').title = d.api_key || '';
|
|
2707
|
+
// 本机可能同时开着两个号池,写清楚当前管的是哪个目录,免得改错池子
|
|
2708
|
+
$('#pHome').textContent = d.home ? ('号池目录:' + d.home) : '';
|
|
2709
|
+
$('#pHome').title = d.home || '';
|
|
2710
|
+
|
|
2711
|
+
const warn = $('#pWarn'); const msgs = [];
|
|
2712
|
+
if (!d.local){
|
|
2713
|
+
msgs.push('当前接口不是本机号池,导入账号不可用。');
|
|
2714
|
+
} else if (!d.online){
|
|
2715
|
+
msgs.push(canManage
|
|
2716
|
+
? '号池没在跑,点「启动号池」。' + (sv.runtime_ready ? '' : '首次启动约 1–3 分钟。')
|
|
2717
|
+
: (sv.message || '号池没在跑。'));
|
|
2718
|
+
}
|
|
2719
|
+
if (d.accounts_error) msgs.push(d.accounts_error);
|
|
2720
|
+
warn.hidden = msgs.length === 0;
|
|
2721
|
+
warn.textContent = msgs.join(' ');
|
|
2722
|
+
|
|
2723
|
+
const list = $('#pAccList'); list.textContent = '';
|
|
2724
|
+
const accs = d.accounts || [];
|
|
2725
|
+
const left = accs.reduce((s, a)=> s + Math.max(0, a.quota || 0), 0);
|
|
2726
|
+
$('#pAccTag').textContent = accs.length ? `${accs.length} 个 · 合计 ${left} 次额度` : '';
|
|
2727
|
+
if (!accs.length){
|
|
2728
|
+
const e = document.createElement('div'); e.className = 'pacc-empty';
|
|
2729
|
+
e.textContent = !d.local ? '读不到账号明细。'
|
|
2730
|
+
: (canManage && !sv.running) ? '号池没在跑。'
|
|
2731
|
+
: '还没有账号。';
|
|
2732
|
+
list.appendChild(e);
|
|
2733
|
+
} else {
|
|
2734
|
+
accs.forEach(a => list.appendChild(poolAccRow(a)));
|
|
2735
|
+
}
|
|
2736
|
+
$('#pImportBtn').disabled = !d.local || !d.online;
|
|
2737
|
+
$('#pKeyNew').disabled = !d.local || !d.online;
|
|
2738
|
+
$('#pApply').disabled = !d.api_key;
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
async function loadPoolPanel(quiet){
|
|
2742
|
+
if (!quiet){ $('#pState').textContent = '读取中…'; $('#pMeta').textContent = ''; }
|
|
2743
|
+
try{
|
|
2744
|
+
const d = await (await fetch('/api/pool/panel', {cache: 'no-store'})).json();
|
|
2745
|
+
renderPool(d);
|
|
2746
|
+
loadPoolProxy();
|
|
2747
|
+
}catch(e){
|
|
2748
|
+
$('#pDot').className = 'dot off';
|
|
2749
|
+
$('#pState').textContent = '读取失败';
|
|
2750
|
+
$('#pMeta').textContent = e.message || String(e);
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
async function poolPost(path, body){
|
|
2755
|
+
const r = await fetch(path, {method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
2756
|
+
body: JSON.stringify(body || {})});
|
|
2757
|
+
const d = await r.json().catch(()=> ({}));
|
|
2758
|
+
if (!r.ok) throw new Error(d.detail || d.message || ('HTTP ' + r.status));
|
|
2759
|
+
return d;
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
async function copyText(text){
|
|
2763
|
+
try{
|
|
2764
|
+
if (navigator.clipboard && navigator.clipboard.writeText){
|
|
2765
|
+
await navigator.clipboard.writeText(text); return true;
|
|
2766
|
+
}
|
|
2767
|
+
}catch(e){ /* iframe 里可能没授权,退回 execCommand */ }
|
|
2768
|
+
try{
|
|
2769
|
+
const ta = document.createElement('textarea');
|
|
2770
|
+
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
2771
|
+
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
|
2772
|
+
return true;
|
|
2773
|
+
}catch(e){ return false; }
|
|
2774
|
+
}
|
|
2775
|
+
document.querySelectorAll('[data-copy]').forEach(b => {
|
|
2776
|
+
b.onclick = async ()=>{
|
|
2777
|
+
const el = document.getElementById(b.dataset.copy);
|
|
2778
|
+
const text = ((el && el.textContent) || '').trim();
|
|
2779
|
+
if (!text || text === '—' || text.indexOf('还没有') === 0){ toast('这里没有可复制的内容', 'err'); return; }
|
|
2780
|
+
if (await copyText(text)){
|
|
2781
|
+
const old = b.textContent; b.textContent = '已复制';
|
|
2782
|
+
setTimeout(()=> { b.textContent = old; }, 1400);
|
|
2783
|
+
} else {
|
|
2784
|
+
toast('复制失败,手动选中那串再 Ctrl+C', 'err');
|
|
2785
|
+
}
|
|
2786
|
+
};
|
|
2787
|
+
});
|
|
2788
|
+
|
|
2789
|
+
$('#pReload').onclick = ()=> loadPoolPanel();
|
|
2790
|
+
|
|
2791
|
+
$('#pUseBuiltin').onclick = async ()=>{
|
|
2792
|
+
const btn = $('#pUseBuiltin');
|
|
2793
|
+
if (!confirm('把出图接口切回本机内置号池(38651)?\n\n会同时对好该号池的 Key 并启动它;当前外部号池(如 38650)的账号与进程不受影响。')) return;
|
|
2794
|
+
const old = btn.textContent;
|
|
2795
|
+
btn.disabled = true; btn.textContent = '切换中…';
|
|
2796
|
+
try{
|
|
2797
|
+
const r = await fetch('/api/pool/use-builtin', {method: 'POST'});
|
|
2798
|
+
const d = await r.json().catch(()=> ({}));
|
|
2799
|
+
if (!r.ok) throw new Error(d.detail || d.message || ('HTTP ' + r.status));
|
|
2800
|
+
if (d.config){ cfgFill(d.config); applyAppName(d.config.app_name); }
|
|
2801
|
+
await loadPoolPanel();
|
|
2802
|
+
refreshPool();
|
|
2803
|
+
toast('已切回内置号池', 'ok');
|
|
2804
|
+
}catch(e){
|
|
2805
|
+
toast('切换失败:' + e.message, 'err');
|
|
2806
|
+
}finally{
|
|
2807
|
+
btn.disabled = false; btn.textContent = old;
|
|
2808
|
+
}
|
|
2809
|
+
};
|
|
2810
|
+
|
|
2811
|
+
/* 号池出口代理:读 / 存 / 测,全部走后端透传,浏览器不碰 admin key。
|
|
2812
|
+
这一栏不写死任何地址:本机当前在用什么代理就显示什么,换机器会跟着变。 */
|
|
2813
|
+
let pEgress = null;
|
|
2814
|
+
function proxyEndpoint(url){
|
|
2815
|
+
const m = /^(\w+):\/\/([^:\/]+)(?::(\d+))?/.exec(String(url || ''));
|
|
2816
|
+
if (!m) return {host: '', port: 0};
|
|
2817
|
+
return {host: (m[2] || '').toLowerCase(), port: Number(m[3] || (m[1] === 'https' ? 443 : 80))};
|
|
2818
|
+
}
|
|
2819
|
+
async function loadPoolProxy(){
|
|
2820
|
+
const input = $('#pProxy'), tag = $('#pProxyTag'), use = $('#pProxyUse'), meta = $('#pProxyLocal');
|
|
2821
|
+
let d = {ok: false}, eg = null;
|
|
2822
|
+
// 号池配置和本机探测并行取,任一路失败都不影响另一路
|
|
2823
|
+
const [pr, er] = await Promise.allSettled([
|
|
2824
|
+
fetch('/api/pool/proxy', {cache: 'no-store'}).then(r => r.json()),
|
|
2825
|
+
fetch('/api/pool/egress', {cache: 'no-store'}).then(r => r.json()),
|
|
2826
|
+
]);
|
|
2827
|
+
if (pr.status === 'fulfilled' && pr.value) d = pr.value;
|
|
2828
|
+
if (er.status === 'fulfilled' && er.value && er.value.ok) eg = er.value;
|
|
2829
|
+
pEgress = eg;
|
|
2830
|
+
|
|
2831
|
+
const saved = (d.ok && d.runtime && d.runtime.proxy_url) || '';
|
|
2832
|
+
const local = (eg && eg.url) || '';
|
|
2833
|
+
// 本机探测到的端口集合:已保存的回环地址不在里面,说明端口已经没了
|
|
2834
|
+
const livePorts = ((eg && eg.ports) || []).map(p => p.port);
|
|
2835
|
+
const ep = proxyEndpoint(saved);
|
|
2836
|
+
const isLoop = ['127.0.0.1', 'localhost', '::1'].includes(ep.host);
|
|
2837
|
+
const alive = !saved || !isLoop || livePorts.includes(ep.port);
|
|
2838
|
+
|
|
2839
|
+
// 已保存的优先显示;没有就先把本机探测值反显出来,点保存才真正生效
|
|
2840
|
+
input.value = saved || local || '';
|
|
2841
|
+
input.placeholder = local ? ('留空=直连(本机可用 ' + local + ')') : '留空=直连';
|
|
2842
|
+
use.hidden = true;
|
|
2843
|
+
const setTag = (text, tone) => { tag.textContent = text; tag.className = tone ? ('tag ' + tone) : 'tag'; };
|
|
2844
|
+
|
|
2845
|
+
if (d.ok === false && d.reason === 'non_local'){
|
|
2846
|
+
setTag('非本机号池');
|
|
2847
|
+
meta.textContent = local ? ('本机当前出口代理:' + local) : '';
|
|
2848
|
+
$('#pProxyMsg').textContent = '';
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
if (!local){
|
|
2852
|
+
setTag(saved ? '已保存' : '直连', saved ? '' : 'bad');
|
|
2853
|
+
meta.textContent = saved
|
|
2854
|
+
? '本机没探测到出口代理,已保存的地址是否可用请点「测试」。'
|
|
2855
|
+
: '本机没探测到出口代理。生图要连 OpenAI,国内网络直连基本会失败。';
|
|
2856
|
+
} else if (!saved){
|
|
2857
|
+
setTag('未保存', 'warn');
|
|
2858
|
+
meta.textContent = '本机出口代理:' + local + ',点「保存代理」启用。';
|
|
2859
|
+
} else if (saved === local){
|
|
2860
|
+
setTag('自动识别', 'ok');
|
|
2861
|
+
meta.textContent = '本机出口代理:' + local;
|
|
2862
|
+
} else if (!alive){
|
|
2863
|
+
setTag('本机连不上', 'bad');
|
|
2864
|
+
meta.textContent = '已保存的 ' + saved + ' 在本机连不上(端口没监听),现在可用的是 ' + local + '。';
|
|
2865
|
+
use.hidden = false; use.textContent = '改用 ' + local;
|
|
2866
|
+
} else {
|
|
2867
|
+
setTag('已保存');
|
|
2868
|
+
meta.textContent = '已保存:' + saved + ';本机还探测到 ' + local + '。';
|
|
2869
|
+
use.hidden = false; use.textContent = '改用 ' + local;
|
|
2870
|
+
}
|
|
2871
|
+
$('#pProxyMsg').textContent = '';
|
|
2872
|
+
}
|
|
2873
|
+
$('#pProxyUse').onclick = ()=>{
|
|
2874
|
+
if (!pEgress || !pEgress.url) return;
|
|
2875
|
+
$('#pProxy').value = pEgress.url;
|
|
2876
|
+
$('#pProxySave').click();
|
|
2877
|
+
};
|
|
2878
|
+
/* 换出口代理后额度必须重算:号池自身 5 分钟才轮询一次,这里立刻推一把 */
|
|
2879
|
+
async function refreshQuotaAfterProxy(){
|
|
2880
|
+
try{
|
|
2881
|
+
const r = await poolPost('/api/pool/accounts/refresh', {});
|
|
2882
|
+
if (!r.ok || !r.progress_id) return;
|
|
2883
|
+
for (let i = 0; i < 90; i++){
|
|
2884
|
+
await new Promise(t => setTimeout(t, 2000));
|
|
2885
|
+
const resp = await fetch('/api/pool/accounts/refresh/' + encodeURIComponent(r.progress_id), {cache: 'no-store'});
|
|
2886
|
+
const p = await resp.json();
|
|
2887
|
+
if (!p.ok) return;
|
|
2888
|
+
$('#pProxyMsg').textContent = '正在重新拉取额度… ' + (p.processed || 0) + '/' + (p.total || 0);
|
|
2889
|
+
if (p.done){
|
|
2890
|
+
await loadPoolPanel(true);
|
|
2891
|
+
const q = p.total_quota || 0;
|
|
2892
|
+
const txt = '额度已刷新:剩余 ' + q + ' 次';
|
|
2893
|
+
$('#pProxyMsg').textContent = txt;
|
|
2894
|
+
toast(txt, q > 0 ? 'ok' : 'err');
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
}catch(e){ /* 刷新失败不影响代理已保存,watcher 会在 5 分钟内兜底 */ }
|
|
2899
|
+
}
|
|
2900
|
+
$('#pProxySave').onclick = async ()=>{
|
|
2901
|
+
const btn = $('#pProxySave'); btn.disabled = true;
|
|
2902
|
+
const old = btn.textContent; btn.textContent = '保存并校验…';
|
|
2903
|
+
try{
|
|
2904
|
+
const d = await poolPost('/api/pool/proxy', {
|
|
2905
|
+
enabled: true, egress_mode: 'single_proxy',
|
|
2906
|
+
proxy_url: $('#pProxy').value.trim(),
|
|
2907
|
+
});
|
|
2908
|
+
if (!d.ok){
|
|
2909
|
+
$('#pProxyMsg').textContent = d.msg || '保存失败';
|
|
2910
|
+
toast('保存失败', 'err');
|
|
2911
|
+
} else if (d.proxy_ok === false){
|
|
2912
|
+
// 填了地址却连不上:多半是端口不对或代理没启动,当场说清楚
|
|
2913
|
+
$('#pProxyMsg').textContent = d.msg || '已保存,但连不上';
|
|
2914
|
+
toast('代理连不上,检查端口或代理是否在运行', 'err');
|
|
2915
|
+
} else {
|
|
2916
|
+
$('#pProxyMsg').textContent = d.msg || '已保存';
|
|
2917
|
+
toast(d.msg || '代理已保存', 'ok');
|
|
2918
|
+
if ($('#pProxy').value.trim()) refreshQuotaAfterProxy();
|
|
2919
|
+
}
|
|
2920
|
+
}catch(e){ $('#pProxyMsg').textContent = e.message; }
|
|
2921
|
+
finally{ btn.disabled = false; btn.textContent = old; await loadPoolPanel(true); }
|
|
2922
|
+
};
|
|
2923
|
+
$('#pProxyTest').onclick = async ()=>{
|
|
2924
|
+
const btn = $('#pProxyTest'); btn.disabled = true;
|
|
2925
|
+
$('#pProxyMsg').textContent = '测连通性中…';
|
|
2926
|
+
try{
|
|
2927
|
+
const d = await poolPost('/api/pool/proxy/test', {url: ''});
|
|
2928
|
+
if (d.ok){
|
|
2929
|
+
$('#pProxyMsg').textContent = '连通 OK(延迟 ' + (d.latency_ms || 0) + 'ms,HTTP ' + (d.status || '') + ')';
|
|
2930
|
+
toast('代理连通', 'ok');
|
|
2931
|
+
} else {
|
|
2932
|
+
$('#pProxyMsg').textContent = '不通:' + (d.error || d.msg || ('HTTP ' + d.status));
|
|
2933
|
+
toast('代理不通', 'err');
|
|
2934
|
+
}
|
|
2935
|
+
}catch(e){ $('#pProxyMsg').textContent = e.message; }
|
|
2936
|
+
finally{ btn.disabled = false; }
|
|
2937
|
+
};
|
|
2938
|
+
|
|
2939
|
+
/* 号池进程归本工作台管:启动 / 停止 / 重启都打自己的后端,不去别的插件 */
|
|
2940
|
+
async function poolService(path, label){
|
|
2941
|
+
if (poolBusy) return;
|
|
2942
|
+
poolBusy = true;
|
|
2943
|
+
['pStart','pStop','pRestart'].forEach(id => { const b = $('#'+id); b.disabled = true; });
|
|
2944
|
+
toast(label + '中…(首次会久一点)');
|
|
2945
|
+
try{
|
|
2946
|
+
const d = await poolPost(path, {});
|
|
2947
|
+
toast(d.message || (d.ok ? label + '完成' : label + '失败'), d.ok ? 'ok' : 'err');
|
|
2948
|
+
}catch(e){ toast(label + '失败:' + e.message, 'err'); }
|
|
2949
|
+
finally{
|
|
2950
|
+
poolBusy = false;
|
|
2951
|
+
await loadPoolPanel(true);
|
|
2952
|
+
refreshPool();
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
$('#pStart').onclick = ()=> poolService('/api/pool/service/start', '启动号池');
|
|
2956
|
+
$('#pRestart').onclick = ()=> poolService('/api/pool/service/restart', '重启号池');
|
|
2957
|
+
$('#pStop').onclick = ()=>{
|
|
2958
|
+
if (confirm('停止号池后,正在生成的图会失败。确定停止?')) poolService('/api/pool/service/stop', '停止号池');
|
|
2959
|
+
};
|
|
2960
|
+
$('#pAuto').onchange = async (e)=>{
|
|
2961
|
+
try{
|
|
2962
|
+
const d = await poolPost('/api/pool/service/auto', {enabled: !!e.target.checked});
|
|
2963
|
+
toast(d.message || '已更新');
|
|
2964
|
+
}catch(err){
|
|
2965
|
+
toast('设置失败:' + err.message, 'err');
|
|
2966
|
+
await loadPoolPanel(true);
|
|
2967
|
+
}
|
|
2968
|
+
};
|
|
2969
|
+
|
|
2970
|
+
/* 选择文件 / 拖入文件:读成文本填进输入框,再走同一个导入接口 */
|
|
2971
|
+
async function poolReadFiles(files){
|
|
2972
|
+
const list = Array.from(files || []).filter(f => f && f.size <= 5 * 1024 * 1024);
|
|
2973
|
+
if (!list.length) return;
|
|
2974
|
+
const chunks = [];
|
|
2975
|
+
for (const f of list){
|
|
2976
|
+
try{
|
|
2977
|
+
const s = (await f.text()).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').trim();
|
|
2978
|
+
if (s) chunks.push(s);
|
|
2979
|
+
}catch(e){ /* 单个文件读失败就跳过,其余照常 */ }
|
|
2980
|
+
}
|
|
2981
|
+
const ta = $('#pImport');
|
|
2982
|
+
const cur = ta.value.trim();
|
|
2983
|
+
ta.value = [cur, ...chunks].filter(Boolean).join('\n');
|
|
2984
|
+
$('#pFileName').textContent = list.length === 1 ? list[0].name : (list.length + ' 个文件');
|
|
2985
|
+
$('#pFileName').title = list.map(f => f.name).join('、');
|
|
2986
|
+
const m = $('#pImportMsg');
|
|
2987
|
+
m.style.color = 'var(--ink-3)';
|
|
2988
|
+
m.textContent = chunks.length ? '已读取,点「导入并授权入池」' : '文件里没有内容';
|
|
2989
|
+
}
|
|
2990
|
+
$('#pPick').onclick = e => { e.stopPropagation(); $('#pFile').click(); };
|
|
2991
|
+
$('#pFile').onchange = async e => { await poolReadFiles(e.target.files); e.target.value = ''; };
|
|
2992
|
+
$('#pClear').onclick = e => {
|
|
2993
|
+
e.stopPropagation();
|
|
2994
|
+
$('#pImport').value = ''; $('#pFile').value = '';
|
|
2995
|
+
$('#pFileName').textContent = '未选择文件'; $('#pFileName').title = '';
|
|
2996
|
+
$('#pImportMsg').textContent = '';
|
|
2997
|
+
};
|
|
2998
|
+
const pZone = $('#pDropZone');
|
|
2999
|
+
pZone.addEventListener('click', e => {
|
|
3000
|
+
if (e.target.closest('button')) return;
|
|
3001
|
+
$('#pFile').click();
|
|
3002
|
+
});
|
|
3003
|
+
pZone.addEventListener('keydown', e => {
|
|
3004
|
+
if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); $('#pFile').click(); }
|
|
3005
|
+
});
|
|
3006
|
+
pZone.addEventListener('dragover', e => { e.preventDefault(); pZone.classList.add('drag'); });
|
|
3007
|
+
pZone.addEventListener('dragleave', ()=> pZone.classList.remove('drag'));
|
|
3008
|
+
pZone.addEventListener('drop', async e => {
|
|
3009
|
+
e.preventDefault(); pZone.classList.remove('drag');
|
|
3010
|
+
await poolReadFiles(e.dataTransfer && e.dataTransfer.files);
|
|
3011
|
+
});
|
|
3012
|
+
|
|
3013
|
+
const pDrop = $('#pImport');
|
|
3014
|
+
pDrop.addEventListener('dragover', e => { e.preventDefault(); pDrop.style.borderColor = 'var(--primary)'; });
|
|
3015
|
+
pDrop.addEventListener('dragleave', ()=> { pDrop.style.borderColor = ''; });
|
|
3016
|
+
pDrop.addEventListener('drop', async e => {
|
|
3017
|
+
e.preventDefault(); pDrop.style.borderColor = '';
|
|
3018
|
+
await poolReadFiles(e.dataTransfer && e.dataTransfer.files);
|
|
3019
|
+
});
|
|
3020
|
+
|
|
3021
|
+
$('#pImportBtn').onclick = async ()=>{
|
|
3022
|
+
const msg = $('#pImportMsg');
|
|
3023
|
+
const text = $('#pImport').value.trim();
|
|
3024
|
+
if (!text){ msg.style.color = '#d4380d'; msg.textContent = '先选文件或粘贴内容'; return; }
|
|
3025
|
+
const btn = $('#pImportBtn');
|
|
3026
|
+
btn.disabled = true;
|
|
3027
|
+
msg.style.color = 'var(--ink-3)';
|
|
3028
|
+
msg.textContent = '正在导入…';
|
|
3029
|
+
try{
|
|
3030
|
+
const d = await poolPost('/api/pool/accounts/import', {text});
|
|
3031
|
+
if (!d.ok){ msg.style.color = '#d4380d'; msg.textContent = d.message || '导入失败'; return; }
|
|
3032
|
+
const parts = [];
|
|
3033
|
+
if (d.added) parts.push(`新增 ${d.added} 个`);
|
|
3034
|
+
if (d.skipped) parts.push(`号池里已有,跳过 ${d.skipped} 个`);
|
|
3035
|
+
if (d.blacklisted) parts.push(`剔除名单跳过 ${d.blacklisted} 个`);
|
|
3036
|
+
if (d.limited) parts.push(`${d.limited} 个无额度已标限流`);
|
|
3037
|
+
if (d.refreshed) parts.push(`校验通过 ${d.refreshed} 个`);
|
|
3038
|
+
if (d.errors && d.errors.length) parts.push(`失败 ${d.errors.length} 个(${d.errors.slice(0, 2).join(';')})`);
|
|
3039
|
+
if (d.bad) parts.push(`${d.bad} 行没认出 token`);
|
|
3040
|
+
if (d.note) parts.push(d.note);
|
|
3041
|
+
msg.style.color = (d.errors && d.errors.length) ? '#d4380d' : '#1f9d6b';
|
|
3042
|
+
msg.textContent = parts.join(' · ') || '没有新增';
|
|
3043
|
+
if (d.added){
|
|
3044
|
+
$('#pImport').value = ''; $('#pFileName').textContent = '未选择文件';
|
|
3045
|
+
toast(`号池新增 ${d.added} 个账号`, 'ok'); refreshPool();
|
|
3046
|
+
}
|
|
3047
|
+
await loadPoolPanel(true);
|
|
3048
|
+
}catch(e){
|
|
3049
|
+
msg.style.color = '#d4380d'; msg.textContent = '导入失败:' + e.message;
|
|
3050
|
+
}finally{ btn.disabled = false; }
|
|
3051
|
+
};
|
|
3052
|
+
|
|
3053
|
+
async function poolDelete(a){
|
|
3054
|
+
if (!confirm(`从号池删除 ${a.email}?\n\n只是把它移出这个号池,账号本身不受影响,以后还能再导入。`)) return;
|
|
3055
|
+
try{
|
|
3056
|
+
const d = await poolPost('/api/pool/accounts/delete', {ids: [a.id]});
|
|
3057
|
+
if (!d.ok){ toast(d.message || '删除失败', 'err'); return; }
|
|
3058
|
+
toast(`已删除 ${d.removed} 个账号`);
|
|
3059
|
+
await loadPoolPanel(true); refreshPool();
|
|
3060
|
+
}catch(e){ toast('删除失败:' + e.message, 'err'); }
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
async function poolToggle(a){
|
|
3064
|
+
const disabled = a.status !== '禁用';
|
|
3065
|
+
try{
|
|
3066
|
+
const d = await poolPost('/api/pool/accounts/status', {ids: [a.id], disabled});
|
|
3067
|
+
if (!d.ok){ toast(d.message || '操作失败', 'err'); return; }
|
|
3068
|
+
toast(disabled ? '已停用,号池不再派图给它' : '已启用,重新参与轮转');
|
|
3069
|
+
await loadPoolPanel(true); refreshPool();
|
|
3070
|
+
}catch(e){ toast('操作失败:' + e.message, 'err'); }
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
$('#pKeyNew').onclick = async ()=>{
|
|
3074
|
+
if (!confirm('生成一把新的用户 Key?\n\n老的 Key 不会失效,正在用它的脚本不受影响。')) return;
|
|
3075
|
+
const btn = $('#pKeyNew');
|
|
3076
|
+
btn.disabled = true; btn.textContent = '生成中…';
|
|
3077
|
+
try{
|
|
3078
|
+
const d = await poolPost('/api/pool/key/ensure', {new: true});
|
|
3079
|
+
if (!d.ok){ toast(d.message || '生成失败', 'err'); return; }
|
|
3080
|
+
toast('新密钥已生成,点复制拿走');
|
|
3081
|
+
await loadPoolPanel(true);
|
|
3082
|
+
}catch(e){ toast('生成失败:' + e.message, 'err'); }
|
|
3083
|
+
finally{ btn.disabled = false; btn.textContent = '重新生成'; }
|
|
3084
|
+
};
|
|
3085
|
+
|
|
3086
|
+
$('#pApply').onclick = async ()=>{
|
|
3087
|
+
if (!poolPanel || !poolPanel.api_key) return;
|
|
3088
|
+
const btn = $('#pApply'); const hint = $('#pApplyHint');
|
|
3089
|
+
btn.disabled = true; hint.textContent = '保存中…';
|
|
3090
|
+
try{
|
|
3091
|
+
const r = await fetch('/api/config', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
3092
|
+
body: JSON.stringify({base_url: poolPanel.base_url, api_key: poolPanel.api_key,
|
|
3093
|
+
monitor_mode: 'pool', pool_dir: poolPanel.home})});
|
|
3094
|
+
const d = await r.json();
|
|
3095
|
+
if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
|
|
3096
|
+
cfgFill(d); applyAppName(d.app_name);
|
|
3097
|
+
hint.textContent = '已切到这个号池';
|
|
3098
|
+
toast('已切到本地号池出图', 'ok');
|
|
3099
|
+
refreshPool();
|
|
3100
|
+
}catch(e){ hint.textContent = ''; toast('切换失败:' + e.message, 'err'); }
|
|
3101
|
+
finally{ btn.disabled = false; }
|
|
3102
|
+
};
|
|
3103
|
+
|
|
3104
|
+
$('#cfgOpenDir').onclick = async ()=>{
|
|
3105
|
+
const r = $('#cfgResult');
|
|
3106
|
+
try{
|
|
3107
|
+
const d = await (await fetch('/api/config/open-dir', {method:'POST'})).json();
|
|
3108
|
+
if (d.ok){ r.style.color = '#1f9d6b'; r.textContent = '已打开:' + d.path; }
|
|
3109
|
+
else { r.style.color = '#d4380d'; r.textContent = (d.message || '打不开') + ':' + d.path; }
|
|
3110
|
+
}catch(e){ r.style.color = '#d4380d'; r.textContent = '打开目录失败:' + e.message; }
|
|
3111
|
+
};
|
|
3112
|
+
$('#openConfig').onclick = openConfig;
|
|
3113
|
+
$('#cfgClose').onclick = ()=> $('#configDlg').close();
|
|
3114
|
+
$('#cfgTest').onclick = testConn;
|
|
3115
|
+
$('#cfgSave').onclick = saveConfig;
|
|
3116
|
+
|
|
3117
|
+
function setPoolDetail(t){ $('#poolDetail').textContent = t; }
|
|
3118
|
+
|
|
3119
|
+
$('#genText').onclick = ()=> run('text', $('#textStatus'));
|
|
3120
|
+
$('#polishText').onclick = polishText;
|
|
3121
|
+
$('#genImage').onclick = runImage;
|
|
3122
|
+
$('#redoExit').onclick = ()=> clearRedo(false);
|
|
3123
|
+
$('#coverPrev').onclick = ()=>{ if (state.results.length > 1) selectResult((state.idx - 1 + state.results.length) % state.results.length); };
|
|
3124
|
+
$('#coverNext').onclick = ()=>{ if (state.results.length > 1) selectResult((state.idx + 1) % state.results.length); };
|
|
3125
|
+
$('#viewClose').onclick = ()=> $('#viewDlg').close();
|
|
3126
|
+
$('#viewPrev').onclick = ()=> stepView(-1);
|
|
3127
|
+
$('#viewNext').onclick = ()=> stepView(1);
|
|
3128
|
+
/* 键盘左右翻,仅在 大图 打开时接管,别影响编辑区打字 */
|
|
3129
|
+
document.addEventListener('keydown', e=>{
|
|
3130
|
+
const dlg = $('#viewDlg');
|
|
3131
|
+
if (!dlg || !dlg.open) return;
|
|
3132
|
+
if (e.key === 'ArrowLeft'){ e.preventDefault(); stepView(-1); }
|
|
3133
|
+
else if (e.key === 'ArrowRight'){ e.preventDefault(); stepView(1); }
|
|
3134
|
+
else if (e.key === '0'){ resetZoom(); }
|
|
3135
|
+
});
|
|
3136
|
+
/* 排版预览点一下就开大图(以前要双击,没人猜得到) */
|
|
3137
|
+
$('#cover').addEventListener('click', e=>{
|
|
3138
|
+
if (e.target.closest('#cover img')) openViewer();
|
|
3139
|
+
});
|
|
3140
|
+
/* 大图里滚轮缩放:指针在图上就放大 / 缩小,1 倍为下限,回到底部自动停 */
|
|
3141
|
+
$('#viewDlg').addEventListener('wheel', e=>{
|
|
3142
|
+
e.preventDefault();
|
|
3143
|
+
zoomAt(e.deltaY < 0 ? 1.15 : 1 / 1.15);
|
|
3144
|
+
}, {passive: false});
|
|
3145
|
+
/* 放大后按住拖动看细节;没放大时不抢事件,双击开图照旧 */
|
|
3146
|
+
(()=>{
|
|
3147
|
+
const img = $('#viewImg');
|
|
3148
|
+
if (!img) return;
|
|
3149
|
+
img.draggable = false;
|
|
3150
|
+
let drag = null;
|
|
3151
|
+
img.addEventListener('pointerdown', e=>{
|
|
3152
|
+
if (ZOOM.s <= 1.001) return;
|
|
3153
|
+
e.preventDefault();
|
|
3154
|
+
drag = {x: e.clientX, y: e.clientY, ox: ZOOM.x, oy: ZOOM.y};
|
|
3155
|
+
img.classList.add('dragging');
|
|
3156
|
+
img.setPointerCapture?.(e.pointerId);
|
|
3157
|
+
applyZoom();
|
|
3158
|
+
});
|
|
3159
|
+
img.addEventListener('pointermove', e=>{
|
|
3160
|
+
if (!drag) return;
|
|
3161
|
+
ZOOM.x = drag.ox + (e.clientX - drag.x);
|
|
3162
|
+
ZOOM.y = drag.oy + (e.clientY - drag.y);
|
|
3163
|
+
clampPan();
|
|
3164
|
+
applyZoom();
|
|
3165
|
+
});
|
|
3166
|
+
const end = ()=>{
|
|
3167
|
+
if (!drag) return;
|
|
3168
|
+
drag = null;
|
|
3169
|
+
img.classList.remove('dragging');
|
|
3170
|
+
applyZoom();
|
|
3171
|
+
};
|
|
3172
|
+
img.addEventListener('pointerup', end);
|
|
3173
|
+
img.addEventListener('pointercancel', end);
|
|
3174
|
+
window.addEventListener('pointerup', end);
|
|
3175
|
+
})();
|
|
3176
|
+
$('#viewDlg').addEventListener('close', resetZoom);
|
|
3177
|
+
document.addEventListener('dblclick', e=>{
|
|
3178
|
+
const shot = e.target.closest('.shot');
|
|
3179
|
+
if (shot){ selectResult(+shot.dataset.i); openViewer(); return; }
|
|
3180
|
+
if (e.target.closest('#cover img')) openViewer();
|
|
3181
|
+
});
|
|
3182
|
+
$('#copyText').onclick = async ()=>{
|
|
3183
|
+
const t = ($('#docTitle').value.trim() + '\n\n' + $('#docBody').value.trim()).trim();
|
|
3184
|
+
if (!t){ toast('还没有可复制的内容'); return; }
|
|
3185
|
+
try { await navigator.clipboard.writeText(t); toast('已复制'); } catch { toast('复制失败,请手动选择'); }
|
|
3186
|
+
};
|
|
3187
|
+
$('#randomCombo').onclick = ()=>{
|
|
3188
|
+
const r = a => a[Math.floor(Math.random()*a.length)];
|
|
3189
|
+
pick('style', r(state.styles)); pick('layout', r(state.layouts)); pick('color', r(state.colors));
|
|
3190
|
+
toast('已随机组合:' + state.sel.style.id + ' / ' + state.sel.layout.id + ' / ' + state.sel.color.id);
|
|
3191
|
+
};
|
|
3192
|
+
$('#addItem').onclick = ()=> addItem();
|
|
3193
|
+
$('#importItems').onclick = ()=> openImport('list');
|
|
3194
|
+
$('#copyInstr').onclick = ()=> copyText($('#instrOut').textContent.trim(), '出图指令已复制');
|
|
3195
|
+
$('#resetInstr').onclick = ()=>{ state.instrCustom = null; syncInstr(); toast('已还原为自动拼接的指令'); };
|
|
3196
|
+
$('#instrOut').addEventListener('input', ()=>{
|
|
3197
|
+
state.instrCustom = $('#instrOut').textContent.trim() || null;
|
|
3198
|
+
instrUi();
|
|
3199
|
+
});
|
|
3200
|
+
$('#composeAll').onclick = composeAll;
|
|
3201
|
+
$('#mood').addEventListener('input', ()=>{ renderMoodChips(); syncInstr(); });
|
|
3202
|
+
$('#ratio').addEventListener('change', ()=>{ syncInstr(); syncRatioUi(); });
|
|
3203
|
+
$('#fusion').addEventListener('change', ()=>{
|
|
3204
|
+
try{ sessionStorage.setItem('wb_fusion_user', $('#fusion').checked ? '1' : '0'); }catch(e){}
|
|
3205
|
+
syncInstr();
|
|
3206
|
+
if (state.items.some(x=>(x.prompt||'').trim())) toast('组合已变,请重新点「生成提示词」或单条「重拼」');
|
|
3207
|
+
});
|
|
3208
|
+
/* 出图模式切换:只改显隐与指令,不动已有输入 */
|
|
3209
|
+
document.querySelectorAll('#omodeSeg button').forEach(b=>{
|
|
3210
|
+
b.onclick = ()=>{
|
|
3211
|
+
state.omode = b.dataset.omode === 'photo' ? 'photo' : 'graphic-text';
|
|
3212
|
+
syncOmode();
|
|
3213
|
+
if (state.omode === 'photo') renderShootChips();
|
|
3214
|
+
syncInstr();
|
|
3215
|
+
};
|
|
3216
|
+
});
|
|
3217
|
+
$('#shootType').addEventListener('input', ()=>{ renderShootChips(); syncInstr(); });
|
|
3218
|
+
document.querySelectorAll('#wsSeg button').forEach(b=>{
|
|
3219
|
+
b.onclick = ()=>{
|
|
3220
|
+
state.ws = b.dataset.ws;
|
|
3221
|
+
document.querySelectorAll('#wsSeg button').forEach(x=> x.classList.toggle('on', x === b));
|
|
3222
|
+
syncInstr();
|
|
3223
|
+
};
|
|
3224
|
+
});
|
|
3225
|
+
$('#importOk').onclick = ()=>{
|
|
3226
|
+
const txt = $('#importText').value;
|
|
3227
|
+
if (importMode === 'prompts'){
|
|
3228
|
+
const parsed = parsePrompts(txt);
|
|
3229
|
+
if (!parsed.length){ toast('没有可导入的提示词'); return; }
|
|
3230
|
+
const n = applyImport(parsed);
|
|
3231
|
+
if (!n){ toast('清单没有变化'); return; }
|
|
3232
|
+
$('#importDlg').close();
|
|
3233
|
+
toast('已导入 ' + n + ' 条提示词,标题正文留空,直接点「批量出图」');
|
|
3234
|
+
return;
|
|
3235
|
+
}
|
|
3236
|
+
const parsed = parseImport(txt);
|
|
3237
|
+
const n = applyImport(parsed);
|
|
3238
|
+
if (!n){ toast('没解析出内容'); return; }
|
|
3239
|
+
$('#importDlg').close();
|
|
3240
|
+
const withP = parsed.filter(x=> (x.prompt || '').trim()).length;
|
|
3241
|
+
toast(withP
|
|
3242
|
+
? ('已识别 ' + n + ' 张卡片,提示词已带回,直接点「批量出图」')
|
|
3243
|
+
: ('已识别 ' + n + ' 张卡片'));
|
|
3244
|
+
};
|
|
3245
|
+
$('#importText').addEventListener('input', countImport);
|
|
3246
|
+
$('#importText').addEventListener('paste', ()=> setTimeout(countImport, 20));
|
|
3247
|
+
$('#importPromptOuter').onclick = async ()=>{
|
|
3248
|
+
openImport('prompts');
|
|
3249
|
+
/* 能读剪贴板就自动预填,复制过来一步到位;没权限就自己粘,照样能用 */
|
|
3250
|
+
try{
|
|
3251
|
+
const t = await navigator.clipboard.readText();
|
|
3252
|
+
if (t && t.trim()){ $('#importText').value = t; countImport(); }
|
|
3253
|
+
}catch(e){}
|
|
3254
|
+
};
|
|
3255
|
+
$('#importCancel').onclick = ()=> $('#importDlg').close();
|
|
3256
|
+
|
|
3257
|
+
/* 整段清单直接粘到清单区:识别到 2 张以上就地分条,不落到输入框里 */
|
|
3258
|
+
$('#itemList').addEventListener('paste', e=>{
|
|
3259
|
+
const cd = e.clipboardData || window.clipboardData;
|
|
3260
|
+
const txt = cd ? cd.getData('text') : '';
|
|
3261
|
+
if (!txt || txt.trim().length < 6) return;
|
|
3262
|
+
const parsed = parseImport(txt);
|
|
3263
|
+
if (parsed.length < 2) return;
|
|
3264
|
+
e.preventDefault();
|
|
3265
|
+
const n = applyImport(parsed);
|
|
3266
|
+
const withP = parsed.filter(x=> (x.prompt || '').trim()).length;
|
|
3267
|
+
toast(withP
|
|
3268
|
+
? ('已识别 ' + n + ' 张卡片,提示词已带回,直接点「批量出图」')
|
|
3269
|
+
: ('已识别 ' + n + ' 张卡片,点「生成提示词」开始拼装'));
|
|
3270
|
+
}, true);
|
|
3271
|
+
|
|
3272
|
+
/* 复制全部:按「卡片 03/08 | 标题 + 代码块」的格式导出,方便存档或贴给别的 AI */
|
|
3273
|
+
function briefAll(){
|
|
3274
|
+
const total = state.items.length;
|
|
3275
|
+
return state.items.map((it, i)=>{
|
|
3276
|
+
const tag = itemNo(it, i) + '/' + String(total).padStart(2, '0');
|
|
3277
|
+
const bare = (it.title || '').replace(/^\d{1,2}[\s..、,,::-]+/, '').trim() || ('第 ' + (i + 1) + ' 张');
|
|
3278
|
+
return '**卡片 ' + tag + ' | ' + bare + '**\n```\n' + (it.prompt || '').trim() + '\n```';
|
|
3279
|
+
}).join('\n\n');
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
$('#downloadAll').onclick = async ()=>{
|
|
3283
|
+
const rs = state.results;
|
|
3284
|
+
const idsAll = rs.map(x=>x.id).filter(Boolean);
|
|
3285
|
+
if (!idsAll.length){ toast('还没有可打包的图片(本轮没有生成成功的图片)'); return; }
|
|
3286
|
+
const btn = $('#downloadAll');
|
|
3287
|
+
btn.disabled = true;
|
|
3288
|
+
toast('正在打包 ' + idsAll.length + ' 张…');
|
|
3289
|
+
try{
|
|
3290
|
+
const r = await fetch('/api/zip', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
3291
|
+
body: JSON.stringify({ids: idsAll})});
|
|
3292
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
3293
|
+
const blob = await r.blob();
|
|
3294
|
+
const a = document.createElement('a');
|
|
3295
|
+
a.href = URL.createObjectURL(blob);
|
|
3296
|
+
a.download = '任务图片-' + new Date().toISOString().slice(0,16).replace(/[:T]/g,'') + '.zip';
|
|
3297
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
3298
|
+
setTimeout(()=>URL.revokeObjectURL(a.href), 5000);
|
|
3299
|
+
toast('已下载 ' + idsAll.length + ' 张(zip)');
|
|
3300
|
+
}catch(e){ toast('打包失败:' + e.message); }
|
|
3301
|
+
finally{ btn.disabled = false; }
|
|
3302
|
+
};
|
|
3303
|
+
$('#copyAll').onclick = async ()=>{
|
|
3304
|
+
if (!state.items.length){ toast('清单还是空的'); return; }
|
|
3305
|
+
if (state.items.some(x=> !x.prompt || !x.prompt.trim())){
|
|
3306
|
+
await composeAll();
|
|
3307
|
+
}
|
|
3308
|
+
copyText(briefAll(), '已复制 ' + state.items.length + ' 条提示词');
|
|
3309
|
+
};
|
|
3310
|
+
$('#docTitle').addEventListener('input', syncPreview);
|
|
3311
|
+
$('#docBody').addEventListener('input', syncPreview);
|
|
3312
|
+
CARD_KINDS.forEach(([k, sfx])=>{
|
|
3313
|
+
$('#empty'+sfx).onclick = ()=> openPicker(k);
|
|
3314
|
+
$('#chg'+sfx).onclick = ()=> openPicker(k);
|
|
3315
|
+
$('#clr'+sfx).onclick = ()=>{ state.sel[k] = null; state.selTouched = true; syncPicks(); };
|
|
3316
|
+
});
|
|
3317
|
+
$('#pickerFilter').addEventListener('input', renderPicker);
|
|
3318
|
+
$('#pickerClose').onclick = ()=> $('#pickerDlg').close();
|
|
3319
|
+
|
|
3320
|
+
|
|
3321
|
+
/* ================= 左侧:标题 + 正文 → 分镜清单 ================= */
|
|
3322
|
+
function esc(v){
|
|
3323
|
+
return String(v == null ? '' : v).replace(/[&<>"']/g, c =>
|
|
3324
|
+
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
function bareTitle(t){
|
|
3328
|
+
return String(t || '')
|
|
3329
|
+
.replace(/^[**【\[((\s]*\d{1,2}[\s..、,,::))\-]+\s*/, '')
|
|
3330
|
+
.replace(/[**】\]))]+$/g, '')
|
|
3331
|
+
.trim();
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
/* 没有编号时,用第一句当标题;长了就截,不出现省略号 */
|
|
3335
|
+
function autoTitle(text){
|
|
3336
|
+
const first = (String(text || '').split(/[。!?!?\n]/)[0] || '')
|
|
3337
|
+
.replace(/^[#*\s]+/, '').trim().replace(/[,,、;;::]+$/, '');
|
|
3338
|
+
return (first.length > 16 ? first.slice(0, 16) : first) || '一张图';
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
/* 结尾收束行 / 话题标签行:只用于「最后一条编号块」的尾部裁剪 */
|
|
3342
|
+
const TAIL_LINE_RE = /^(?:#|[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\uFE0F\s]*(?:最后想|最后告诉|写在最后|总之|以上|共勉|愿你|愿我们|从今天开始|从今天起|最后[,::]|最后))/u;
|
|
3343
|
+
|
|
3344
|
+
/* AI 写「N 条贴图文案」时常是:几句开场引导 → 01|小标题 … 08|小标题 → 结尾收束 + #标签。
|
|
3345
|
+
旧逻辑只看正文首行有没有编号,首行是引导语时整篇按空行切;而条目之间常常一个空行都没有,
|
|
3346
|
+
8 条就塌成 1 条。这里全文扫描编号行,按编号断,丢掉开场引导与尾部收束。 */
|
|
3347
|
+
function splitNumberedBlocks(body){
|
|
3348
|
+
const lines = String(body || '').replace(/\r/g, '').split('\n').map(x => x.trim());
|
|
3349
|
+
const heads = [];
|
|
3350
|
+
lines.forEach((line, i) => {
|
|
3351
|
+
const m = line.match(ITEM_HEAD_RE);
|
|
3352
|
+
if (m) heads.push({i: i, num: parseInt(m[1], 10), title: m[2].replace(/[**]/g, '').trim()});
|
|
3353
|
+
});
|
|
3354
|
+
if (heads.length < 2) return null;
|
|
3355
|
+
if (!heads.every((h, k) => k === 0 || h.num === heads[k - 1].num + 1)) return null;
|
|
3356
|
+
const out = [];
|
|
3357
|
+
heads.forEach((h, k) => {
|
|
3358
|
+
const end = k + 1 < heads.length ? heads[k + 1].i : lines.length;
|
|
3359
|
+
const seg = lines.slice(h.i + 1, end);
|
|
3360
|
+
let cut = seg.length;
|
|
3361
|
+
if (k === heads.length - 1){
|
|
3362
|
+
for (let j = 0; j < seg.length; j++){
|
|
3363
|
+
if (seg[j] && TAIL_LINE_RE.test(seg[j])) { cut = j; break; }
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
out.push({title: h.title, body: seg.slice(0, cut).filter(Boolean).join('\n').trim()});
|
|
3367
|
+
});
|
|
3368
|
+
return out.filter(x => x.title || x.body);
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
/* 正文 → 卡片:优先按编号块断,其次按空行断,最后兜底整段一张 */
|
|
3372
|
+
function splitDoc(title, body){
|
|
3373
|
+
const firstLine = (String(body || '').split('\n').find(l => l.trim()) || '').trim();
|
|
3374
|
+
const hasNum = /^[**【\[((]*\s*\d{1,2}\s*[||、,,..::))]/.test(firstLine);
|
|
3375
|
+
let items = [];
|
|
3376
|
+
if (hasNum){
|
|
3377
|
+
items = parseImport(body);
|
|
3378
|
+
} else {
|
|
3379
|
+
const numbered = splitNumberedBlocks(body);
|
|
3380
|
+
if (numbered && numbered.length >= 2){
|
|
3381
|
+
items = numbered;
|
|
3382
|
+
} else {
|
|
3383
|
+
const blocks = String(body || '').split(/\n\s*\n/).map(b => b.trim()).filter(Boolean)
|
|
3384
|
+
.filter(b => !b.split('\n').every(l => /^\s*#/.test(l.trim())));
|
|
3385
|
+
if (blocks.length > 1) items = blocks.map(b => ({title: autoTitle(b), body: b}));
|
|
3386
|
+
else if (String(body || '').trim()) items = [{title: title || autoTitle(body), body}];
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
if (!items.length && title) items = [{title, body: ''}];
|
|
3390
|
+
return items
|
|
3391
|
+
.map(x => ({title: bareTitle(x.title), body: (x.body || '').trim()}))
|
|
3392
|
+
.filter(x => x.title || x.body)
|
|
3393
|
+
.map((x, i) => ({
|
|
3394
|
+
title: String(i + 1).padStart(2, '0') + ' ' + (x.title || autoTitle(x.body)),
|
|
3395
|
+
body: x.body, scene: '', prompt: ''
|
|
3396
|
+
}));
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
async function splitToItems(){
|
|
3400
|
+
const title = $('#docTitle').value.trim();
|
|
3401
|
+
const body = $('#docBody').value.trim();
|
|
3402
|
+
if (!title && !body){
|
|
3403
|
+
// 左侧全空也允许:给一条空白分镜,直接靠选型 + 出图指令(可手改)出提示词
|
|
3404
|
+
if (state.items.length){ toast('左侧为空,保留现有清单直接出图'); return; }
|
|
3405
|
+
state.items = [{title: '', body: '', scene: '', prompt: ''}];
|
|
3406
|
+
state.group = '';
|
|
3407
|
+
$('#groupTag').textContent = '';
|
|
3408
|
+
renderItems();
|
|
3409
|
+
const st = $('#splitStatus');
|
|
3410
|
+
st.className = 'status ok';
|
|
3411
|
+
st.textContent = '空白分镜 · 提示词按出图指令拼';
|
|
3412
|
+
await composeAll();
|
|
3413
|
+
toast('已按出图指令生成提示词');
|
|
3414
|
+
return;
|
|
3415
|
+
}
|
|
3416
|
+
const items = splitDoc(title, body);
|
|
3417
|
+
if (!items.length){ toast('没拆出内容'); return; }
|
|
3418
|
+
// 封面也拆进清单:标题做主视觉大图,编号固定 00 排最前(标记在 cover 上,标题不带 00),不要时可以点 × 删掉
|
|
3419
|
+
items.unshift({title: bareTitle(title) || autoTitle(body), body: '', scene: '', prompt: '', cover: true});
|
|
3420
|
+
const dirty = state.items.some(x=> (x.title || '').trim() || (x.body || '').trim());
|
|
3421
|
+
if (dirty && !confirm('左侧标题 + 正文会重新生成 ' + items.length + ' 张卡片,当前清单(含已改过的提示词)会被替换,继续?')) return;
|
|
3422
|
+
|
|
3423
|
+
state.items = items;
|
|
3424
|
+
state.group = title;
|
|
3425
|
+
$('#groupTag').textContent = title ? '整组主题:' + title : '';
|
|
3426
|
+
renderItems();
|
|
3427
|
+
|
|
3428
|
+
const st = $('#splitStatus');
|
|
3429
|
+
st.className = 'status run';
|
|
3430
|
+
st.textContent = `已拆 ${items.length} 张,正在拼提示词…`;
|
|
3431
|
+
await composeAll();
|
|
3432
|
+
st.className = 'status ok';
|
|
3433
|
+
st.textContent = `已拆 ${items.length} 张 · 提示词已生成,可逐条修改`;
|
|
3434
|
+
toast(`已拆成 ${items.length} 张卡片`);
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
/* ================= 生成历史 ================= */
|
|
3438
|
+
function fmtTime(ts){
|
|
3439
|
+
if (!ts) return '—';
|
|
3440
|
+
const d = new Date(ts * 1000), p = n => String(n).padStart(2, '0');
|
|
3441
|
+
return `${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
3442
|
+
}
|
|
3443
|
+
/* 清掉孤立代理项与替换字符:JS 的 slice 按 UTF-16 单元切,切到 emoji 一半会显示成 ▯ */
|
|
3444
|
+
function safeText(v){
|
|
3445
|
+
const out = [];
|
|
3446
|
+
for (const ch of String(v == null ? '' : v)){
|
|
3447
|
+
const c = ch.codePointAt(0);
|
|
3448
|
+
if (c === 0xFFFD || (c >= 0xD800 && c <= 0xDFFF)) continue;
|
|
3449
|
+
out.push(ch);
|
|
3450
|
+
}
|
|
3451
|
+
return out.join('');
|
|
3452
|
+
}
|
|
3453
|
+
/* 按 Unicode 码点截断(不是 UTF-16 单元),超出加省略号,不会再切坏 emoji */
|
|
3454
|
+
function clipText(v, n){
|
|
3455
|
+
const a = Array.from(safeText(v));
|
|
3456
|
+
return a.length > n ? a.slice(0, n).join('') + '…' : a.join('');
|
|
3457
|
+
}
|
|
3458
|
+
function fmtSize(n){
|
|
3459
|
+
if (!n) return '';
|
|
3460
|
+
return n > 1048576 ? (n / 1048576).toFixed(1) + 'MB' : Math.round(n / 1024) + 'KB';
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
/* 历史:按「一次批量出图」分组,默认折叠,展开才加载这一批的缩略图 */
|
|
3464
|
+
const HIST_SIZES = [20, 50, 100];
|
|
3465
|
+
function pageSize(){
|
|
3466
|
+
const v = +($('#historyPageSize').value || 20);
|
|
3467
|
+
return HIST_SIZES.includes(v) ? v : 20;
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
async function loadTasks(page){
|
|
3471
|
+
const limit = pageSize();
|
|
3472
|
+
const p = Math.max(1, +page || 1);
|
|
3473
|
+
const q = $('#historyFilter').value.trim();
|
|
3474
|
+
const box = $('#taskList');
|
|
3475
|
+
box.innerHTML = '<div class="task-empty">加载中…</div>';
|
|
3476
|
+
let d;
|
|
3477
|
+
try{
|
|
3478
|
+
d = await (await fetch(`/api/tasks?limit=${limit}&offset=${(p - 1) * limit}&q=${encodeURIComponent(q)}`)).json();
|
|
3479
|
+
}catch(e){
|
|
3480
|
+
box.innerHTML = '<div class="task-empty">历史加载失败,点「刷新」重试</div>';
|
|
3481
|
+
return;
|
|
3482
|
+
}
|
|
3483
|
+
state.tasks = {page: d.page || p, limit, pages: d.pages || 1,
|
|
3484
|
+
total: d.total || 0, items: d.items || [], q};
|
|
3485
|
+
renderTasks();
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
function renderTasks(){
|
|
3489
|
+
const box = $('#taskList'); box.innerHTML = '';
|
|
3490
|
+
const t = state.tasks;
|
|
3491
|
+
$('#historyCount').textContent = t.total + ' 个任务';
|
|
3492
|
+
$('#historyEmpty').hidden = !!t.items.length;
|
|
3493
|
+
$('#taskPageInfo').textContent = t.total
|
|
3494
|
+
? `第 ${t.page} / ${t.pages} 页 · 共 ${t.total} 个任务 · 每页 ${t.limit}`
|
|
3495
|
+
: '';
|
|
3496
|
+
$('#taskPrev').disabled = t.page <= 1;
|
|
3497
|
+
$('#taskNext').disabled = t.page >= t.pages;
|
|
3498
|
+
|
|
3499
|
+
if (!t.items.length){
|
|
3500
|
+
box.innerHTML = '<div class="task-empty">' + (t.q ? '没有匹配「' + esc(t.q) + '」的任务' : '还没有生成记录') + '</div>';
|
|
3501
|
+
return;
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
t.items.forEach(task=>{
|
|
3505
|
+
const items = task.items || [];
|
|
3506
|
+
const nImg = items.filter(i=>i.image_url).length;
|
|
3507
|
+
const nText = (task.modes && task.modes.text) || 0;
|
|
3508
|
+
const row = document.createElement('div');
|
|
3509
|
+
row.className = 'task-row';
|
|
3510
|
+
row.innerHTML =
|
|
3511
|
+
'<div class="task-head">'
|
|
3512
|
+
+ '<span class="tw">▶</span>'
|
|
3513
|
+
+ `<b>${esc(task.title || '未命名任务')}</b>`
|
|
3514
|
+
+ `<span class="chipx">${task.total} 条</span>`
|
|
3515
|
+
+ (nImg ? `<span class="chipx img">${nImg} 张图</span>` : '')
|
|
3516
|
+
+ (nText ? `<span class="chipx">文字 ${nText}</span>` : '')
|
|
3517
|
+
+ `<span class="tm"><span class="okc">${task.done} 成功</span>`
|
|
3518
|
+
+ (task.error ? ` · <span class="errc">${task.error} 失败</span>` : '')
|
|
3519
|
+
+ ` · ${fmtTime(task.finished || task.created)}</span>`
|
|
3520
|
+
+ '<span class="spacer"></span>'
|
|
3521
|
+
+ '<span class="tacts">'
|
|
3522
|
+
+ '<button class="mini" data-act="load" type="button" title="把这个任务的分镜和提示词载回左侧,改完可以继续出图">载入</button>'
|
|
3523
|
+
+ '<button class="mini" data-act="bundle" type="button" title="把这一批的图片和文案打包成 zip 下载">打包</button>'
|
|
3524
|
+
+ '<button class="mini del" data-act="del" type="button" title="删除这个任务,图片文件一起清理">删除</button>'
|
|
3525
|
+
+ '</span>'
|
|
3526
|
+
+ '</div>'
|
|
3527
|
+
+ '<div class="task-body" hidden></div>';
|
|
3528
|
+
const body = row.querySelector('.task-body');
|
|
3529
|
+
row.querySelector('.task-head').onclick = ()=>{
|
|
3530
|
+
const open = row.classList.toggle('open');
|
|
3531
|
+
body.hidden = !open;
|
|
3532
|
+
if (open && body.dataset.filled !== '1'){
|
|
3533
|
+
fillTaskGrid(body, items, task);
|
|
3534
|
+
body.dataset.filled = '1';
|
|
3535
|
+
}
|
|
3536
|
+
};
|
|
3537
|
+
// 动作按钮在头部里,点它们不能连带把任务折叠/展开
|
|
3538
|
+
row.querySelectorAll('.tacts [data-act]').forEach(b=>{
|
|
3539
|
+
b.onclick = e=>{
|
|
3540
|
+
e.stopPropagation();
|
|
3541
|
+
if (b.dataset.act === 'load') loadTaskIntoWorkbench(task);
|
|
3542
|
+
else if (b.dataset.act === 'bundle') location.href = '/api/tasks/' + encodeURIComponent(task.task_id) + '/bundle';
|
|
3543
|
+
else deleteHistoryTask(task);
|
|
3544
|
+
};
|
|
3545
|
+
});
|
|
3546
|
+
box.appendChild(row);
|
|
3547
|
+
});
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
function fillTaskGrid(body, items, task){
|
|
3551
|
+
const grid = document.createElement('div');
|
|
3552
|
+
grid.className = 'task-grid';
|
|
3553
|
+
items.forEach((rec, i)=> grid.appendChild(histCard(rec, task, i, items)));
|
|
3554
|
+
if (!items.length) grid.innerHTML = '<div class="task-empty">这个任务没有记录</div>';
|
|
3555
|
+
body.appendChild(grid);
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
function histCard(rec, task, idx, siblings){
|
|
3559
|
+
const ok = rec.status === 'done' && rec.image_url;
|
|
3560
|
+
const isText = rec.status === 'done' && !rec.image_url;
|
|
3561
|
+
const el = document.createElement('div');
|
|
3562
|
+
el.className = 'hist-card';
|
|
3563
|
+
el.innerHTML =
|
|
3564
|
+
(ok
|
|
3565
|
+
? `<div class="hc-thumb" style="aspect-ratio:${arCss(rec.ratio) || '3 / 4'}"><img src="${rec.thumb_url || rec.image_url}" loading="lazy" decoding="async" alt=""></div>`
|
|
3566
|
+
: isText
|
|
3567
|
+
? `<div class="hc-thumb text">${esc((rec.body || '').slice(0, 120)) || '文字任务'}</div>`
|
|
3568
|
+
: `<div class="hc-thumb failed">生成失败<br>${esc((rec.error || '未知错误').slice(0, 50))}</div>`)
|
|
3569
|
+
+ '<div class="hc-body">'
|
|
3570
|
+
+ `<b>${esc(rec.title || '未命名')}</b>`
|
|
3571
|
+
+ `<span class="hc-sub">${(rec.width && rec.height) ? rec.width + '×' + rec.height : (isText ? '文字' : '—')}${rec.bytes ? ' · ' + fmtSize(rec.bytes) : ''}</span>`
|
|
3572
|
+
+ `<span class="hc-sub">${esc(rec.account || '')}</span>`
|
|
3573
|
+
+ '<div class="hc-acts">'
|
|
3574
|
+
+ (ok ? '<button class="mini" data-act="view" type="button">大图</button><button class="mini" data-act="dl" type="button">下载</button>' : '')
|
|
3575
|
+
+ '<button class="mini" data-act="reuse" type="button" title="只载入这一张,改完出图后原位替换原任务里这张">重做</button>'
|
|
3576
|
+
+ '</div></div>';
|
|
3577
|
+
const img = el.querySelector('img');
|
|
3578
|
+
if (img){
|
|
3579
|
+
img.onclick = ()=> openHistoryView(rec, siblings);
|
|
3580
|
+
img.onerror = ()=>{
|
|
3581
|
+
// 派生图接口异常时退回原图,仍不行才提示
|
|
3582
|
+
if (rec.image_url && !img.dataset.fallback){
|
|
3583
|
+
img.dataset.fallback = '1'; img.src = rec.image_url; return;
|
|
3584
|
+
}
|
|
3585
|
+
img.parentNode.className = 'hc-thumb failed'; img.parentNode.textContent = '缩略图不可用';
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
el.querySelectorAll('[data-act]').forEach(b=>{
|
|
3589
|
+
b.onclick = ()=>{
|
|
3590
|
+
const act = b.dataset.act;
|
|
3591
|
+
if (act === 'view') openHistoryView(rec, siblings);
|
|
3592
|
+
else if (act === 'dl') downloadHistory(rec);
|
|
3593
|
+
else redoHistoryOne(rec, task, idx);
|
|
3594
|
+
};
|
|
3595
|
+
});
|
|
3596
|
+
return el;
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
function openHistoryView(rec, siblings){
|
|
3600
|
+
if (!rec.image_url){ toast('这条没有图片'); return; }
|
|
3601
|
+
// 传进来的 siblings 是这条任务里的全部记录,筛成有图的,就能左右翻整个任务
|
|
3602
|
+
const list = (siblings && siblings.length ? siblings : [rec]).filter(r=>r && r.image_url);
|
|
3603
|
+
const at = list.findIndex(r=>r.id === rec.id);
|
|
3604
|
+
VIEW.kind = 'history';
|
|
3605
|
+
VIEW.list = list.length ? list : [rec];
|
|
3606
|
+
VIEW.i = at < 0 ? 0 : at;
|
|
3607
|
+
const dlg = $('#viewDlg');
|
|
3608
|
+
if (!dlg.open) dlg.showModal();
|
|
3609
|
+
resetZoom();
|
|
3610
|
+
renderView(VIEW.i);
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
function downloadHistory(rec){
|
|
3614
|
+
if (!rec.download_url){ toast('这条没有图片'); return; }
|
|
3615
|
+
const a = document.createElement('a');
|
|
3616
|
+
a.href = rec.download_url; a.download = '';
|
|
3617
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
3618
|
+
toast('开始下载:' + (rec.title || ''));
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
/* 单张重做:只把历史里这一张的图型 / 风格 / 主题色 / 比例 / 出图模式 / 提示词载回左侧,
|
|
3622
|
+
清单只留一条。出图时带原 task_id + seq + replace_id,成功后原位替换那一张,
|
|
3623
|
+
不新增历史任务。任务行的「载入」不走这里,它照样开一个新 task_id、生成一条新记录。 */
|
|
3624
|
+
async function redoHistoryOne(rec, task, idx){
|
|
3625
|
+
let full;
|
|
3626
|
+
try{
|
|
3627
|
+
const r = await fetch('/api/history/' + encodeURIComponent(rec.id));
|
|
3628
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
3629
|
+
full = await r.json();
|
|
3630
|
+
}catch(e){ toast('读取这张图的记录失败:' + e.message); return; }
|
|
3631
|
+
|
|
3632
|
+
const tid = (full.task_id || '').trim();
|
|
3633
|
+
if (!tid){
|
|
3634
|
+
// 早期记录只有合成任务号,没法原位替换,退回原来的「加回清单」
|
|
3635
|
+
state.items.push({title: full.title || rec.title || '', body: full.body || '',
|
|
3636
|
+
scene: '', prompt: full.prompt || ''});
|
|
3637
|
+
renderItems();
|
|
3638
|
+
toast('这条是早期记录,没有任务编号,已加回清单(不能原位替换)');
|
|
3639
|
+
return;
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
const dirty = state.items.some(x=> (x.title || '').trim() || (x.body || '').trim())
|
|
3643
|
+
|| $('#docTitle').value.trim();
|
|
3644
|
+
if (dirty && !confirm('单张重做会用这一张替换左侧当前内容,继续?')) return;
|
|
3645
|
+
|
|
3646
|
+
const cov = isCoverItem(full);
|
|
3647
|
+
const rawTitle = full.title || rec.title || '';
|
|
3648
|
+
const title = cov ? rawTitle.replace(/^00[\s..、,,::||]+/, '') : rawTitle;
|
|
3649
|
+
const body = full.body || '';
|
|
3650
|
+
const prompt = full.prompt || '';
|
|
3651
|
+
state.items = [{title, body, scene: '', prompt, cover: cov || undefined}];
|
|
3652
|
+
renderItems();
|
|
3653
|
+
|
|
3654
|
+
// 图型 / 风格 / 主题色 / 比例 / 出图模式按这一张当时的选择还原
|
|
3655
|
+
const pool = {layout:'layouts', style:'styles', color:'colors'};
|
|
3656
|
+
['layout', 'style', 'color'].forEach(k=>{
|
|
3657
|
+
const id = full[k + '_id'];
|
|
3658
|
+
if (!id) return;
|
|
3659
|
+
const hit = (state[pool[k]] || []).find(x=> String(x.id) === String(id));
|
|
3660
|
+
if (hit) state.sel[k] = hit;
|
|
3661
|
+
});
|
|
3662
|
+
syncPicks();
|
|
3663
|
+
const sel = $('#ratio');
|
|
3664
|
+
if (full.ratio && [...sel.options].some(o=> o.value === full.ratio)) sel.value = full.ratio;
|
|
3665
|
+
syncRatioUi();
|
|
3666
|
+
state.omode = full.image_mode === 'photo' ? 'photo' : 'graphic-text';
|
|
3667
|
+
$('#shootType').value = full.shoot_type || '';
|
|
3668
|
+
syncOmode(); renderShootChips(); syncInstr();
|
|
3669
|
+
|
|
3670
|
+
// 正文框只放这一条方便改错别字;提交时用原任务的整篇正文,不覆盖历史还原数据
|
|
3671
|
+
$('#topic').value = '';
|
|
3672
|
+
$('#docTitle').value = full.group || (task && task.title) || '';
|
|
3673
|
+
$('#docBody').value = body;
|
|
3674
|
+
syncPreview();
|
|
3675
|
+
|
|
3676
|
+
const seq = (typeof full.seq === 'number') ? full.seq : (idx || 0);
|
|
3677
|
+
state.redo = {
|
|
3678
|
+
task_id: tid, job_id: rec.id, seq,
|
|
3679
|
+
group: full.group || (task && task.group) || '',
|
|
3680
|
+
doc_body: full.doc_body || '',
|
|
3681
|
+
label: (task && task.title) || full.group || tid,
|
|
3682
|
+
origTitle: title, origBody: body, origPrompt: prompt
|
|
3683
|
+
};
|
|
3684
|
+
renderRedoBar();
|
|
3685
|
+
$('#historyDlg').close();
|
|
3686
|
+
setTimeout(()=> window.scrollTo({top: 0, behavior: 'smooth'}), 120);
|
|
3687
|
+
toast('已进入单张重做:改完文案或提示词,点「出图」就会原位替换这一张');
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
function renderRedoBar(){
|
|
3691
|
+
const bar = $('#redoBar');
|
|
3692
|
+
if (!bar) return;
|
|
3693
|
+
const r = state.redo;
|
|
3694
|
+
bar.hidden = !r;
|
|
3695
|
+
if (r) $('#redoText').textContent =
|
|
3696
|
+
'单张重做 · 原任务「' + clipText(safeText(r.label), 18) + '」第 ' + (r.seq + 1)
|
|
3697
|
+
+ ' 张 · 出图成功后原位替换,不新增历史记录';
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
function clearRedo(success){
|
|
3701
|
+
if (!state.redo) return;
|
|
3702
|
+
state.redo = null;
|
|
3703
|
+
renderRedoBar();
|
|
3704
|
+
if (success){
|
|
3705
|
+
toast('已替换原任务里的这一张,单张重做结束');
|
|
3706
|
+
loadTasks(1).catch(()=>{});
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
/* 清单被加过头就别替换了,免得一口气往原任务里塞好几张 */
|
|
3711
|
+
function redoGuard(){
|
|
3712
|
+
if (!state.redo) return true;
|
|
3713
|
+
if (state.items.length !== 1){
|
|
3714
|
+
toast('单张重做模式下清单只能有一条,删掉多余的,或点「退出重做」按整套生成');
|
|
3715
|
+
return false;
|
|
3716
|
+
}
|
|
3717
|
+
return true;
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
/* 单张重做的公共提交参数:原任务号、原序号、原整篇正文、原任务标题都不能丢 */
|
|
3721
|
+
function redoExtra(redo){
|
|
3722
|
+
if (!redo) return {};
|
|
3723
|
+
return {task_id: redo.task_id, seq: redo.seq, replace_id: redo.job_id,
|
|
3724
|
+
group: redo.group, doc_body: redo.doc_body};
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
/* 改了分镜标题 / 正文但提示词没动:出图前自动重拼一次,
|
|
3728
|
+
否则提示词里嵌的还是旧文字,错别字会原样画进新图。手改过提示词则尊重手改。 */
|
|
3729
|
+
async function redoPrompt(it, redo, prompt){
|
|
3730
|
+
if (!redo) return prompt;
|
|
3731
|
+
const changed = (it.title || '') !== redo.origTitle || (it.body || '') !== redo.origBody;
|
|
3732
|
+
if (!changed || (it.prompt || '') !== redo.origPrompt) return prompt;
|
|
3733
|
+
try{
|
|
3734
|
+
const d = await postCompose([it]);
|
|
3735
|
+
it.prompt = leadFor(it) + d.items[0].prompt;
|
|
3736
|
+
toast('文案已改,提示词自动重拼了一次');
|
|
3737
|
+
return it.prompt;
|
|
3738
|
+
}catch(e){ toast('提示词重拼失败:' + e.message); return prompt; }
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
/* 把整个任务载回左侧:分镜清单 + 当时的图型/风格/主题色/比例。
|
|
3742
|
+
提示词原样带回,所以改完直接出图,不用重新拼一遍。 */
|
|
3743
|
+
async function loadTaskIntoWorkbench(task){
|
|
3744
|
+
// 整体载入 = 再生成一整套、开一条新历史记录,先退出单张重做
|
|
3745
|
+
state.redo = null;
|
|
3746
|
+
renderRedoBar();
|
|
3747
|
+
let d;
|
|
3748
|
+
try{
|
|
3749
|
+
const r = await fetch('/api/tasks/' + encodeURIComponent(task.task_id));
|
|
3750
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
3751
|
+
d = await r.json();
|
|
3752
|
+
}catch(e){ toast('载入失败:拿不到任务内容'); return; }
|
|
3753
|
+
|
|
3754
|
+
// 文字任务在历史里没有可用分镜,别把空清单塞进左侧
|
|
3755
|
+
const rows = (d.items || []).filter(x=> (x.mode || 'image') === 'image'
|
|
3756
|
+
&& ((x.title || '').trim() || (x.body || '').trim() || (x.prompt || '').trim()));
|
|
3757
|
+
if (!rows.length){ toast('这个任务没有可载入的分镜(可能是文字任务)'); return; }
|
|
3758
|
+
|
|
3759
|
+
const dirty = state.items.some(x=> (x.title || '').trim() || (x.body || '').trim())
|
|
3760
|
+
|| $('#docTitle').value.trim();
|
|
3761
|
+
if (dirty && !confirm('载入会用这个任务的 ' + rows.length + ' 条分镜替换左侧当前内容,继续?')) return;
|
|
3762
|
+
|
|
3763
|
+
// 封面条:老记录标题带 00 前缀,载入时去掉(识别靠 cover 标记 / 00 前缀 / 提示词开头的主视觉要求)
|
|
3764
|
+
state.items = rows.map(x=>{
|
|
3765
|
+
const cov = isCoverItem(x);
|
|
3766
|
+
const t = (x.title || '').trim();
|
|
3767
|
+
return {title: cov ? t.replace(/^00[\s..、,,::||]+/, '') : t,
|
|
3768
|
+
body: x.body || '', scene: '', prompt: x.prompt || '', cover: cov || undefined};
|
|
3769
|
+
});
|
|
3770
|
+
renderItems();
|
|
3771
|
+
|
|
3772
|
+
// 右侧预览把这一批已经出好的图一起带出来:以前载入只回了文字,
|
|
3773
|
+
// 图得重点一次批量出图才看得见,等于把历史里现成的成果藏了。
|
|
3774
|
+
// seq 用行序,和分镜清单一一对齐;topic 取去掉 00 前缀的标题。
|
|
3775
|
+
state.results = rows.map((x, i)=>({x, i})).filter(({x})=> x.image_url).map(({x, i})=>({
|
|
3776
|
+
url: x.image_url,
|
|
3777
|
+
preview: '/api/history/' + x.id + '/preview',
|
|
3778
|
+
topic: (((state.items[i] && state.items[i].title) || x.title || '配图')).slice(0, 60),
|
|
3779
|
+
seq: i, id: x.id, account: '',
|
|
3780
|
+
}));
|
|
3781
|
+
state.idx = 0;
|
|
3782
|
+
renderStrip();
|
|
3783
|
+
if (!state.results.length) clearCover();
|
|
3784
|
+
|
|
3785
|
+
// 画风/图型/主题色还原成当时那套,不然提示词里的画风和卡片对不上
|
|
3786
|
+
const pool = {layout:'layouts', style:'styles', color:'colors'};
|
|
3787
|
+
['layout', 'style', 'color'].forEach(k=>{
|
|
3788
|
+
const id = d[k + '_id'];
|
|
3789
|
+
if (!id) return;
|
|
3790
|
+
const hit = (state[pool[k]] || []).find(x=> String(x.id) === String(id));
|
|
3791
|
+
if (hit) state.sel[k] = hit;
|
|
3792
|
+
});
|
|
3793
|
+
syncPicks();
|
|
3794
|
+
|
|
3795
|
+
if (d.ratio){
|
|
3796
|
+
const sel = $('#ratio');
|
|
3797
|
+
if ([...sel.options].some(o=> o.value === d.ratio)) sel.value = d.ratio;
|
|
3798
|
+
}
|
|
3799
|
+
syncRatioUi();
|
|
3800
|
+
// 出图模式和拍摄类型一起还原;老记录没有这两个字段,按图文处理
|
|
3801
|
+
state.omode = d.image_mode === 'photo' ? 'photo' : 'graphic-text';
|
|
3802
|
+
$('#shootType').value = d.shoot_type || '';
|
|
3803
|
+
syncOmode(); renderShootChips(); syncInstr();
|
|
3804
|
+
// 当时用过参考图的话一起带回来(图还在 refs/ 里)
|
|
3805
|
+
if (d.ref_id){
|
|
3806
|
+
state.ref = {id: d.ref_id, url: '/refs/' + d.ref_id + '.png', w: null, h: null};
|
|
3807
|
+
const probe = new Image();
|
|
3808
|
+
probe.onload = ()=>{
|
|
3809
|
+
if (state.ref && state.ref.id === d.ref_id){
|
|
3810
|
+
state.ref.w = probe.naturalWidth; state.ref.h = probe.naturalHeight; syncRef();
|
|
3811
|
+
}
|
|
3812
|
+
};
|
|
3813
|
+
probe.src = state.ref.url;
|
|
3814
|
+
} else if (state.ref){
|
|
3815
|
+
state.ref = null;
|
|
3816
|
+
}
|
|
3817
|
+
syncRef();
|
|
3818
|
+
|
|
3819
|
+
// 正文:新记录存了整篇正文;早于 2026-11-03 的老记录没存,
|
|
3820
|
+
// 用这条任务各分镜还原:标题行 + 正文段,和正文框里原本的写法(01|先存后花)对齐
|
|
3821
|
+
const rawBody = (d.doc_body || '').trim();
|
|
3822
|
+
const fmtHead = t=>{
|
|
3823
|
+
const str = (t || '').trim();
|
|
3824
|
+
const m = str.match(/^(\d{1,3})\s+(.+)$/);
|
|
3825
|
+
return m ? (m[1].padStart(2, '0') + '|' + m[2]) : str;
|
|
3826
|
+
};
|
|
3827
|
+
// 封面条(00 开头)不回写正文框:它是拆出来的主视觉,不是正文的一节
|
|
3828
|
+
const joined = rows.filter(x=> !isCoverItem(x)).map(x=>{
|
|
3829
|
+
const head = fmtHead(x.title), body = (x.body || '').trim();
|
|
3830
|
+
return [head, body].filter(Boolean).join('\n');
|
|
3831
|
+
}).filter(Boolean).join('\n\n');
|
|
3832
|
+
$('#docBody').value = rawBody || joined;
|
|
3833
|
+
const bodyGuessed = !rawBody && !!joined;
|
|
3834
|
+
|
|
3835
|
+
// 选题不再用 d.topic 兜底:那是单条分镜的标题(「01 先存后花」),
|
|
3836
|
+
// 塞进选题框就成了一条假选题。旧版本已经填进去的,这里顺手清掉
|
|
3837
|
+
const tv = $('#topic').value.trim();
|
|
3838
|
+
if (tv && rows.some(x=> (x.title || '').trim() === tv)) $('#topic').value = '';
|
|
3839
|
+
|
|
3840
|
+
const group = d.group || d.title || '';
|
|
3841
|
+
if (group) $('#docTitle').value = group;
|
|
3842
|
+
// 上面都是程序赋值,不会触发 input 事件,右侧预览得手动推一次
|
|
3843
|
+
syncPreview();
|
|
3844
|
+
|
|
3845
|
+
$('#historyDlg').close();
|
|
3846
|
+
// 载入的是另一条任务,跟之前绑定的草稿没关系了:断绑,之后的自动暂存另起一份,
|
|
3847
|
+
// 免得把用户在别处暂存的那份悄悄写掉
|
|
3848
|
+
bindDraft(null, '');
|
|
3849
|
+
markContentClean();
|
|
3850
|
+
toast(bodyGuessed
|
|
3851
|
+
? ('已载入 ' + rows.length + ' 条分镜;这条老记录没存整篇正文,正文已用分镜内容拼接')
|
|
3852
|
+
: ('已载入 ' + rows.length + ' 条分镜,改完提示词直接点批量出图'));
|
|
3853
|
+
// 回页面顶部:载入完最该先看到的是标题和正文有没有回来,
|
|
3854
|
+
// 之前滚到分镜清单中间,一屏都是 04 05 06,看着像顺序错了、正文也没了
|
|
3855
|
+
setTimeout(()=> window.scrollTo({top: 0, behavior: 'smooth'}), 120);
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
async function deleteHistoryTask(task){
|
|
3859
|
+
const n = task.total || (task.items || []).length;
|
|
3860
|
+
const nImg = (task.items || []).filter(x=> x.image_url).length;
|
|
3861
|
+
const tip = '删除任务「' + (task.title || '未命名') + '」?\n\n'
|
|
3862
|
+
+ '这个任务下 ' + n + ' 条记录' + (nImg ? '、' + nImg + ' 张图片' : '')
|
|
3863
|
+
+ '会一起删掉,图片文件同时清理,删完找不回来。';
|
|
3864
|
+
if (!confirm(tip)) return;
|
|
3865
|
+
try{
|
|
3866
|
+
const r = await fetch('/api/tasks/' + encodeURIComponent(task.task_id), {method:'DELETE'});
|
|
3867
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
3868
|
+
const d = await r.json();
|
|
3869
|
+
toast('已删除 ' + (d.deleted || 0) + ' 条记录' + (d.images ? ' · 清掉 ' + d.images + ' 个文件' : ''));
|
|
3870
|
+
await loadTasks(state.tasks.page);
|
|
3871
|
+
if (!state.tasks.items.length && state.tasks.page > 1) await loadTasks(state.tasks.page - 1);
|
|
3872
|
+
}catch(e){ toast('删除失败:' + e.message); }
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
async function openHistory(){
|
|
3876
|
+
$('#historyDlg').showModal();
|
|
3877
|
+
await loadTasks(1);
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3880
|
+
/* ---------- 停止当前生图任务 ---------- */
|
|
3881
|
+
async function stopAllJobs(){
|
|
3882
|
+
if (!confirm('确定停止当前生图任务?\n正在出图的请求会被中断,已完成的图片不受影响。')) return;
|
|
3883
|
+
_stopRequested = true; // 批量提交 / 轮询循环见到立即退出
|
|
3884
|
+
const sg = $('#stopGen'), ms = $('#monStop');
|
|
3885
|
+
if (sg) sg.disabled = true;
|
|
3886
|
+
if (ms) ms.disabled = true;
|
|
3887
|
+
try{
|
|
3888
|
+
const r = await fetch('/api/generate/stop', {
|
|
3889
|
+
method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'
|
|
3890
|
+
});
|
|
3891
|
+
const d = await r.json();
|
|
3892
|
+
if (!r.ok || !d.ok) throw new Error(d.message || ('HTTP ' + r.status));
|
|
3893
|
+
clearBatch(); // 清断点,刷新页面不会自动接上被停掉的批次
|
|
3894
|
+
const st = $('#imageStatus');
|
|
3895
|
+
if (st){ st.className = 'status err'; st.textContent = d.message || '已停止生图'; }
|
|
3896
|
+
toast(d.message || '已停止当前生图任务', {type:'info', title:'已停止'});
|
|
3897
|
+
refreshPool();
|
|
3898
|
+
if ($('#monitorDlg').open) refreshMonitor();
|
|
3899
|
+
}catch(e){
|
|
3900
|
+
toast('停止失败:' + e.message, {type:'err', title:'停止生图'});
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
/* ================= 运行监控 ================= */
|
|
3905
|
+
const ST_LABEL = {ready:'可用', busy:'出图中', limited:'限流', unhealthy:'已下线', unbound:'未绑定', pending:'待验证', error:'异常'};
|
|
3906
|
+
let monTimer = null;
|
|
3907
|
+
|
|
3908
|
+
function kpi(label, val, cls){
|
|
3909
|
+
return `<div class="mon-kpi"><span>${label}</span><b class="${cls || ''}">${val}</b></div>`;
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
function agoText(ts){
|
|
3913
|
+
if (!ts) return '—';
|
|
3914
|
+
const s = Math.max(0, Math.round(Date.now() / 1000 - ts));
|
|
3915
|
+
if (s < 60) return s + ' 秒前';
|
|
3916
|
+
if (s < 3600) return Math.round(s / 60) + ' 分钟前';
|
|
3917
|
+
if (s < 86400) return Math.round(s / 3600) + ' 小时前';
|
|
3918
|
+
return Math.round(s / 86400) + ' 天前';
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
function renderMonitor(d){
|
|
3922
|
+
// 账号表固定按 #1…#N 原序展示,只给正在出图的号加底色高亮。
|
|
3923
|
+
// 曾经把 busy 行置顶,结果监控里序号乱跳(#4 跑到第一行),看不出轮转推进到哪了。
|
|
3924
|
+
const accs = d.accounts || [];
|
|
3925
|
+
const run = d.running || [];
|
|
3926
|
+
const hist = d.history || {};
|
|
3927
|
+
state._monActive = !!d.running_now; // 有图在跑才 3 秒一刷,空闲 12 秒
|
|
3928
|
+
const _ms2 = $('#monStop');
|
|
3929
|
+
if (_ms2) _ms2.disabled = !(run.length || d.queued);
|
|
3930
|
+
// 明细拉不到时(换接口后 admin-key 对不上)用号池自报的可用数,别显示成 0/0
|
|
3931
|
+
const poolS = d.pool || {};
|
|
3932
|
+
// 与右上角用完全相同的算式,保证两处数字完全一致(都是 32/35)
|
|
3933
|
+
const kReady = accs.length ? accs.filter(a=>a.status==='ready' && a.env_id).length : Number(poolS.active || 0);
|
|
3934
|
+
const kTotal = accs.length ? accs.filter(a=>a.env_id).length : Number(poolS.total || 0);
|
|
3935
|
+
$('#monSum').innerHTML =
|
|
3936
|
+
kpi('账号可用', `${kReady}/${kTotal}`, kReady ? 'ok' : 'err')
|
|
3937
|
+
+ kpi('并行任务', `${run.length}/${d.workers || 1}`, run.length ? 'warn' : '')
|
|
3938
|
+
+ kpi('排队', d.queued || 0, d.queued ? 'warn' : '')
|
|
3939
|
+
+ kpi('今日成功', hist.today_done || 0, 'ok')
|
|
3940
|
+
+ kpi('今日失败', hist.today_error || 0, hist.today_error ? 'err' : '')
|
|
3941
|
+
+ kpi('历史总量', hist.total || 0, '');
|
|
3942
|
+
|
|
3943
|
+
let lastUsedAcc = null;
|
|
3944
|
+
for (const a of accs){
|
|
3945
|
+
if (a.env_id && a.last_used && (!lastUsedAcc || a.last_used > lastUsedAcc.last_used)) lastUsedAcc = a;
|
|
3946
|
+
}
|
|
3947
|
+
$('#monAccHint').textContent = accs.length
|
|
3948
|
+
? (lastUsedAcc ? `上次用 #${lastUsedAcc.env_id},下次顺延` : '账号轮转中')
|
|
3949
|
+
// 空表不能当成「0 个账号」:换接口后 admin-key 对不上就是这个样子,得说清原因
|
|
3950
|
+
: (d.accounts_error || (d.pool && d.pool.mode === 'single'
|
|
3951
|
+
? '当前是通用接口模式,没有账号明细'
|
|
3952
|
+
: '账号明细加载中…'));
|
|
3953
|
+
const chk = d.check || {};
|
|
3954
|
+
if (chk.running){
|
|
3955
|
+
$('#monCheckHint').textContent = `检测中 ${chk.done}/${chk.total}…`;
|
|
3956
|
+
$('#monCheck').disabled = true;
|
|
3957
|
+
} else {
|
|
3958
|
+
$('#monCheckHint').textContent = chk.finished ? `上次检测 ${agoText(chk.finished)}` : '';
|
|
3959
|
+
$('#monCheck').disabled = false;
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
const tb = $('#monAccBody'); tb.innerHTML = '';
|
|
3963
|
+
// 跟号池管理页一致:异常/限流等不可用的号沉到列表末尾,别挤在前面干扰查看与生成
|
|
3964
|
+
const _accBad = s => ['limited', 'error', 'unhealthy', 'unbound', 'pending'].includes(s);
|
|
3965
|
+
const _accOrder = [...accs].sort((a, b) => {
|
|
3966
|
+
const ba = _accBad(a.status) ? 1 : 0, bb = _accBad(b.status) ? 1 : 0;
|
|
3967
|
+
if (ba !== bb) return ba - bb;
|
|
3968
|
+
return (a.env_id || 1e9) - (b.env_id || 1e9);
|
|
3969
|
+
});
|
|
3970
|
+
_accOrder.forEach(a=>{
|
|
3971
|
+
const tr = document.createElement('tr');
|
|
3972
|
+
if (a.status === 'busy') tr.className = 'on-air';
|
|
3973
|
+
// 备注列改成放号池的剩余额度:正常号写「剩 N 次」,用满的写「额度已用满」,
|
|
3974
|
+
// 有异常时接在后面,一个格子同时回答「这个号还能不能用」和「为什么不能用」。
|
|
3975
|
+
const q = (typeof a.quota === 'number') ? a.quota : null;
|
|
3976
|
+
const qtxt = q == null ? '' : (q <= 0 ? '额度已用满' : '剩 ' + q + ' 次');
|
|
3977
|
+
const bad = (a.check_msg && a.check_ok === false) ? a.check_msg
|
|
3978
|
+
: (a.error && a.error !== '限流中' ? a.error : '');
|
|
3979
|
+
const note = bad ? (qtxt ? qtxt + ' · ' + bad : bad) : (qtxt || '—');
|
|
3980
|
+
const noteCls = bad ? 'err' : (q != null && q > 0 && q <= 3 ? 'quota low' : 'quota');
|
|
3981
|
+
const noteTip = [qtxt && (q > 0 ? `配额剩 ${q} 次` : '这个号的出图额度已用满'), bad].filter(Boolean).join(' · ');
|
|
3982
|
+
tr.innerHTML =
|
|
3983
|
+
`<td>${esc(a.label || a.masked)}</td>`
|
|
3984
|
+
+ `<td>${a.env_id ? '#' + a.env_id : '—'}</td>`
|
|
3985
|
+
+ `<td><span class="st ${a.status}">${ST_LABEL[a.status] || a.status}</span></td>`
|
|
3986
|
+
+ `<td>${a.hist_done != null ? a.hist_done : (a.uses || 0)}</td>`
|
|
3987
|
+
+ `<td>${a.hist_error != null ? a.hist_error : (a.fails || 0)}</td>`
|
|
3988
|
+
+ `<td${a.cooldown ? ` title="这个号的出图额度已用满,还要 ${a.cooldown}s 才滚出 24 小时窗口"` : ''}>${a.cooldown ? a.cooldown + 's' : '—'}</td>`
|
|
3989
|
+
+ `<td>${(a.hist_last_ok || a.last_ok) ? agoText(a.hist_last_ok || a.last_ok) : '—'}</td>`
|
|
3990
|
+
+ `<td class="${noteCls}" title="${esc(noteTip || note)}">${esc(note)}</td>`;
|
|
3991
|
+
tb.appendChild(tr);
|
|
3992
|
+
});
|
|
3993
|
+
if (!accs.length){
|
|
3994
|
+
// 空表必须说明原因:换接口后 admin-key 对不上时,以前这里什么都不显示,
|
|
3995
|
+
// 看着像「号池里 0 个账号」,而右上角还挂着上一个号池的 33 个号。
|
|
3996
|
+
const tr = document.createElement('tr');
|
|
3997
|
+
tr.innerHTML = `<td colspan="8" style="color:var(--ink-3);text-align:center;padding:14px 8px">`
|
|
3998
|
+
+ esc(d.accounts_error || '账号明细加载中…') + '</td>';
|
|
3999
|
+
tb.appendChild(tr);
|
|
4000
|
+
}
|
|
4001
|
+
|
|
4002
|
+
const jb = $('#monJobs'); jb.innerHTML = '';
|
|
4003
|
+
$('#monJobHint').textContent = `${run.length} 个在跑 · ${d.queued || 0} 个排队 · 并行上限 ${d.workers || 1}`;
|
|
4004
|
+
if (!run.length && !d.queued){
|
|
4005
|
+
jb.innerHTML = '<div class="mon-none">当前没有任务</div>';
|
|
4006
|
+
} else {
|
|
4007
|
+
run.forEach(j=>{
|
|
4008
|
+
const el = document.createElement('div');
|
|
4009
|
+
el.className = 'mon-job';
|
|
4010
|
+
el.innerHTML = `<span class="st busy">运行中</span><span class="mj-t">${esc(j.job_id)}</span>`
|
|
4011
|
+
+ `<span class="mj-a">${esc(j.account || '')} · 已 ${j.elapsed || 0}s</span>`;
|
|
4012
|
+
jb.appendChild(el);
|
|
4013
|
+
});
|
|
4014
|
+
if (d.queued){
|
|
4015
|
+
const el = document.createElement('div');
|
|
4016
|
+
el.className = 'mon-job';
|
|
4017
|
+
el.innerHTML = `<span class="st pending">排队</span><span class="mj-t">${d.queued} 个任务等待空闲账号</span>`;
|
|
4018
|
+
jb.appendChild(el);
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
// 今日失败账号:失败后会自动换号重试,所以这里不是「坏号名单」,
|
|
4023
|
+
// 而是「今天谁出过错、错了几次」的账,方便判断是不是某个号总体不稳
|
|
4024
|
+
const fb = $('#monFails'); fb.innerHTML = '';
|
|
4025
|
+
const fails = d.fail_accounts || [];
|
|
4026
|
+
const failHint = $('#monFailHint');
|
|
4027
|
+
if (failHint) failHint.textContent = (d.fails_today || 0)
|
|
4028
|
+
? `今天合计 ${d.fails_today} 次(含已换号重试成功的)` : '';
|
|
4029
|
+
if (!fails.length){
|
|
4030
|
+
fb.innerHTML = '<div class="mon-none">今天没有账号失败</div>';
|
|
4031
|
+
} else {
|
|
4032
|
+
fails.forEach(f=>{
|
|
4033
|
+
const el = document.createElement('div');
|
|
4034
|
+
el.className = 'mon-job fail';
|
|
4035
|
+
const when = f.at ? new Date(f.at*1000).toLocaleTimeString('zh-CN', {hour12: false}) : '';
|
|
4036
|
+
el.innerHTML = `<span class="st error">${f.count} 次</span>`
|
|
4037
|
+
+ `<span class="mj-t">${esc(f.account || '未知账号')}</span>`
|
|
4038
|
+
+ `<span class="mj-a">${esc((f.error || '').slice(0, 80))}${when ? ' · ' + when : ''}</span>`;
|
|
4039
|
+
fb.appendChild(el);
|
|
4040
|
+
});
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
const eb = $('#monErrors'); eb.innerHTML = '';
|
|
4044
|
+
const errs = d.recent_errors || [];
|
|
4045
|
+
if (!errs.length){ eb.innerHTML = '<div class="mon-none">最近没有失败</div>'; }
|
|
4046
|
+
errs.forEach(e=>{
|
|
4047
|
+
const el = document.createElement('div');
|
|
4048
|
+
el.className = 'mon-job fail';
|
|
4049
|
+
el.innerHTML = `<span class="st error">失败</span><span class="mj-t">${esc(e.title || e.id)}</span>`
|
|
4050
|
+
+ `<span class="mj-a">${esc(e.error || '')}</span>`;
|
|
4051
|
+
eb.appendChild(el);
|
|
4052
|
+
});
|
|
4053
|
+
|
|
4054
|
+
$('#monUpdated').textContent = '更新于 ' + new Date().toLocaleTimeString('zh-CN', {hour12: false});
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
async function refreshMonitor(){
|
|
4058
|
+
let d;
|
|
4059
|
+
try{ d = await (await fetch('/api/monitor')).json(); }
|
|
4060
|
+
catch(e){ $('#monUpdated').textContent = '连不上服务'; return; }
|
|
4061
|
+
renderMonitor(d);
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
async function openMonitor(){
|
|
4065
|
+
$('#monitorDlg').showModal();
|
|
4066
|
+
await refreshMonitor();
|
|
4067
|
+
scheduleMonitor();
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
/* 出图中 3 秒一刷,空闲时 12 秒。以前写死 3 秒,弹窗开着就在后台一直空转。 */
|
|
4071
|
+
function monitorGap(){
|
|
4072
|
+
return (state._monActive || _batchRunning) ? 3000 : 12000;
|
|
4073
|
+
}
|
|
4074
|
+
let monSeq = 0;
|
|
4075
|
+
function scheduleMonitor(){
|
|
4076
|
+
clearTimeout(monTimer);
|
|
4077
|
+
const my = ++monSeq; // 快速开关弹窗时,旧的定时器认不出自己已过期就会叠上去
|
|
4078
|
+
monTimer = setTimeout(async ()=>{
|
|
4079
|
+
if (my !== monSeq || !$('#monitorDlg').open){ return; }
|
|
4080
|
+
await refreshMonitor();
|
|
4081
|
+
if (my === monSeq && $('#monitorDlg').open) scheduleMonitor();
|
|
4082
|
+
}, monitorGap());
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4085
|
+
function closeMonitor(){
|
|
4086
|
+
monSeq++; // 让还在排队的回调失效
|
|
4087
|
+
clearTimeout(monTimer); monTimer = null;
|
|
4088
|
+
$('#monitorDlg').close();
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
$('#splitToItems').onclick = splitToItems;
|
|
4092
|
+
/* ---------- 一键清空:回到刚打开页面时的状态 ---------- */
|
|
4093
|
+
async function resetAll(){
|
|
4094
|
+
const touched = state.items.some(x=> (x.title || '').trim() || (x.body || '').trim())
|
|
4095
|
+
|| ['#topic', '#docTitle', '#docBody', '#note'].some(id=> ($(id).value || '').trim())
|
|
4096
|
+
|| !!state.sel.style || !!state.sel.layout || !!state.sel.color
|
|
4097
|
+
|| !!state.ref;
|
|
4098
|
+
if (touched && !confirm('清空当前所有内容?\n\n选题、标题、正文、分镜清单、补充要求、参考图都会清掉,'
|
|
4099
|
+
+ '图型 / 风格 / 主题色变成「不限」。\n画幅、情绪、留白、出图模式这些设置保留不动。')) return;
|
|
4100
|
+
|
|
4101
|
+
state.items = [];
|
|
4102
|
+
renderItems();
|
|
4103
|
+
state.group = ''; $('#groupTag').textContent = '';
|
|
4104
|
+
['#topic', '#docTitle', '#docBody', '#note'].forEach(id=>{ $(id).value = ''; });
|
|
4105
|
+
$('#docRaw').textContent = '';
|
|
4106
|
+
syncPreview();
|
|
4107
|
+
|
|
4108
|
+
state.instrCustom = null;
|
|
4109
|
+
|
|
4110
|
+
// 三张卡清成「不限」:不选也照样能出图(有参考图就照参考图,没参考图就按文案拼)
|
|
4111
|
+
state.sel.style = null;
|
|
4112
|
+
state.sel.color = null;
|
|
4113
|
+
state.sel.layout = null;
|
|
4114
|
+
state.selTouched = true; // 用户主动清过,别在空手出图时又给他随机选一套
|
|
4115
|
+
state.demoPending = false;
|
|
4116
|
+
syncPicks();
|
|
4117
|
+
state.ref = null; syncRef();
|
|
4118
|
+
state.redo = null; renderRedoBar();
|
|
4119
|
+
hideDemoNote();
|
|
4120
|
+
|
|
4121
|
+
state.results = []; state.idx = 0; renderStrip(); clearCover();
|
|
4122
|
+
['#imageStatus', '#textStatus', '#splitStatus'].forEach(id=>{
|
|
4123
|
+
const el = $(id); if (el){ el.className = 'status'; el.textContent = ''; }
|
|
4124
|
+
});
|
|
4125
|
+
|
|
4126
|
+
// 断开和上一份草稿的关系:清空后再「暂存」应该是一份新的,别写进旧草稿
|
|
4127
|
+
state.draftId = null;
|
|
4128
|
+
state.draftKey = '';
|
|
4129
|
+
try{
|
|
4130
|
+
localStorage.removeItem('wb_draft_id'); localStorage.removeItem('wb_draft_key');
|
|
4131
|
+
localStorage.removeItem('wb_session'); // 清空就是真清空,刷新不该再把内容捞回来
|
|
4132
|
+
}catch(e){}
|
|
4133
|
+
markContentClean(); // 空白页不算改动,别让自动暂存把空壳存进草稿箱
|
|
4134
|
+
refreshDraftCount();
|
|
4135
|
+
toast('已清空,图型 / 风格 / 主题色都变成不限');
|
|
4136
|
+
}
|
|
4137
|
+
|
|
4138
|
+
/* ---------- 参考图:点击 / 拖入 / Ctrl+V 粘贴 ---------- */
|
|
4139
|
+
$('#emptyRef').onclick = ()=> $('#refFile').click();
|
|
4140
|
+
$('#chgRef').onclick = ()=> $('#refFile').click();
|
|
4141
|
+
$('#clrRef').onclick = clearRef;
|
|
4142
|
+
$('#refFile').addEventListener('change', e=>{
|
|
4143
|
+
const f = e.target.files && e.target.files[0];
|
|
4144
|
+
e.target.value = '';
|
|
4145
|
+
uploadRef(f);
|
|
4146
|
+
});
|
|
4147
|
+
['dragenter', 'dragover'].forEach(ev=> $('#cardRef').addEventListener(ev, e=>{
|
|
4148
|
+
e.preventDefault(); $('#cardRef').classList.add('drag');
|
|
4149
|
+
}));
|
|
4150
|
+
['dragleave', 'drop'].forEach(ev=> $('#cardRef').addEventListener(ev, e=>{
|
|
4151
|
+
e.preventDefault(); $('#cardRef').classList.remove('drag');
|
|
4152
|
+
}));
|
|
4153
|
+
$('#cardRef').addEventListener('drop', e=>{
|
|
4154
|
+
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
|
4155
|
+
if (f) uploadRef(f);
|
|
4156
|
+
});
|
|
4157
|
+
document.addEventListener('paste', e=>{
|
|
4158
|
+
const list = (e.clipboardData && e.clipboardData.items) || [];
|
|
4159
|
+
for (const it of list){
|
|
4160
|
+
if (it.type && it.type.indexOf('image/') === 0){
|
|
4161
|
+
uploadRef(it.getAsFile());
|
|
4162
|
+
e.preventDefault();
|
|
4163
|
+
return;
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
4166
|
+
});
|
|
4167
|
+
$('#resetAll').onclick = resetAll;
|
|
4168
|
+
$('#demoNoteX').onclick = hideDemoNote;
|
|
4169
|
+
|
|
4170
|
+
$('#openHistory').onclick = openHistory;
|
|
4171
|
+
$('#historyClose').onclick = ()=> $('#historyDlg').close();
|
|
4172
|
+
$('#historyRefresh').onclick = ()=> loadTasks(state.tasks.page || 1);
|
|
4173
|
+
$('#taskPrev').onclick = ()=> loadTasks((state.tasks.page || 1) - 1);
|
|
4174
|
+
$('#taskNext').onclick = ()=> loadTasks((state.tasks.page || 1) + 1);
|
|
4175
|
+
$('#historyPageSize').onchange = ()=> loadTasks(1);
|
|
4176
|
+
let _histT;
|
|
4177
|
+
$('#historyFilter').addEventListener('input', ()=>{
|
|
4178
|
+
clearTimeout(_histT); _histT = setTimeout(()=> loadTasks(1), 300);
|
|
4179
|
+
});
|
|
4180
|
+
|
|
4181
|
+
/* ---------- 暂存 / 草稿箱 ---------- */
|
|
4182
|
+
let _draftT = null;
|
|
4183
|
+
function draftSnapshot(){
|
|
4184
|
+
return {
|
|
4185
|
+
topic: $('#topic').value, title: $('#docTitle').value, body: $('#docBody').value,
|
|
4186
|
+
group: state.group || '', note: $('#note').value, mood: $('#mood').value,
|
|
4187
|
+
ws: state.ws, ratio: $('#ratio').value, mode: state.mode || 'image',
|
|
4188
|
+
image_mode: state.omode || 'graphic-text', shoot_type: $('#shootType').value || '',
|
|
4189
|
+
sel: {style: (state.sel.style && state.sel.style.id) || '',
|
|
4190
|
+
layout: (state.sel.layout && state.sel.layout.id) || '',
|
|
4191
|
+
color: (state.sel.color && state.sel.color.id) || ''},
|
|
4192
|
+
ref: state.ref ? {id: state.ref.id, url: state.ref.url, w: state.ref.w, h: state.ref.h} : null,
|
|
4193
|
+
items: state.items.map(it=>({title: it.title || '', body: it.body || '',
|
|
4194
|
+
scene: it.scene || '', prompt: it.prompt || ''})),
|
|
4195
|
+
};
|
|
4196
|
+
}
|
|
4197
|
+
|
|
4198
|
+
/* 把一份快照铺回界面(不动草稿绑定):载入草稿和刷新后恢复会话共用 */
|
|
4199
|
+
function applySnapshot(d){
|
|
4200
|
+
hideDemoNote(); // 换了一份内容,「随机示例」的说明就不再成立
|
|
4201
|
+
state.demoPending = false;
|
|
4202
|
+
$('#topic').value = d.topic || '';
|
|
4203
|
+
$('#docTitle').value = d.title || '';
|
|
4204
|
+
$('#docBody').value = d.body || '';
|
|
4205
|
+
$('#note').value = d.note || '';
|
|
4206
|
+
$('#mood').value = d.mood || '';
|
|
4207
|
+
const rsel = $('#ratio');
|
|
4208
|
+
rsel.value = [...rsel.options].some(o=> o.value === d.ratio) ? d.ratio : '3:4';
|
|
4209
|
+
syncRatioUi();
|
|
4210
|
+
state.group = d.group || '';
|
|
4211
|
+
state.ws = d.ws || 'normal';
|
|
4212
|
+
state.mode = d.mode || 'image';
|
|
4213
|
+
state.omode = d.image_mode === 'photo' ? 'photo' : 'graphic-text';
|
|
4214
|
+
$('#shootType').value = d.shoot_type || '';
|
|
4215
|
+
state.items = (d.items || []).map(it=>({title: it.title || '', body: it.body || '',
|
|
4216
|
+
scene: it.scene || '', prompt: it.prompt || ''}));
|
|
4217
|
+
const sel = d.sel || {};
|
|
4218
|
+
// 存的是空串=当时就是「不限」,得真的清回 null,否则会沿用页面上的默认组合
|
|
4219
|
+
const pick1 = (kind, id) => {
|
|
4220
|
+
const pool = state[kind + 's'] || [];
|
|
4221
|
+
state.sel[kind] = (id && pool.find(x=>x.id === id)) || null;
|
|
4222
|
+
};
|
|
4223
|
+
pick1('style', sel.style); pick1('layout', sel.layout); pick1('color', sel.color);
|
|
4224
|
+
state.ref = d.ref && d.ref.id ? d.ref : null;
|
|
4225
|
+
state.redo = null; renderRedoBar();
|
|
4226
|
+
syncPicks(); renderMoodChips(); syncRef(); syncInstr(); renderItems();
|
|
4227
|
+
syncOmode(); renderShootChips();
|
|
4228
|
+
// 预览条:会话快照里有图就铺回来;草稿没有这个字段,按空处理,别让上一次的图串到这份草稿上
|
|
4229
|
+
state.results = (d.results || []).filter(r=> r && r.url).map((r, i)=>({
|
|
4230
|
+
url: r.url, preview: r.preview || r.url, topic: r.topic || '配图',
|
|
4231
|
+
seq: typeof r.seq === 'number' ? r.seq : i, id: r.id || '', account: r.account || ''}));
|
|
4232
|
+
state.idx = 0;
|
|
4233
|
+
renderStrip();
|
|
4234
|
+
if (!state.results.length) clearCover();
|
|
4235
|
+
// 载入草稿是直接改 value,不会触发 input 事件,右侧预览得手动推一次
|
|
4236
|
+
syncPreview();
|
|
4237
|
+
markContentClean(); // 刚载入的内容不算改动,别紧接着又自动存一遍
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
function applyDraft(d){
|
|
4241
|
+
applySnapshot(d);
|
|
4242
|
+
bindDraft(d.id || null, draftKeyOf(d));
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4245
|
+
/* ---------- 会话自动保存:刷新页面别把分镜清单丢掉 ---------- */
|
|
4246
|
+
let _sessT = 0;
|
|
4247
|
+
function saveSession(){
|
|
4248
|
+
try{
|
|
4249
|
+
const snap = draftSnapshot();
|
|
4250
|
+
// 空工作区不回写,避免刚打开页面就把上次的内容擦掉
|
|
4251
|
+
if (!snap.topic.trim() && !snap.title.trim() && !snap.body.trim() && !snap.items.length) return;
|
|
4252
|
+
snap.t = Date.now();
|
|
4253
|
+
// 预览条也一起存:刷新后右侧不该只剩文字,图得原地回来
|
|
4254
|
+
snap.results = state.results.map(r=>({url: r.url, preview: r.preview,
|
|
4255
|
+
topic: r.topic, seq: r.seq, id: r.id, account: r.account || ''}));
|
|
4256
|
+
localStorage.setItem('wb_session', JSON.stringify(snap));
|
|
4257
|
+
}catch(e){}
|
|
4258
|
+
}
|
|
4259
|
+
function saveSessionSoon(){
|
|
4260
|
+
clearTimeout(_sessT);
|
|
4261
|
+
_sessT = setTimeout(saveSession, 800);
|
|
4262
|
+
noteContentChanged(); // 同一批触发点也用来排自动暂存
|
|
4263
|
+
}
|
|
4264
|
+
function restoreSession(){
|
|
4265
|
+
let s = null;
|
|
4266
|
+
try{ s = JSON.parse(localStorage.getItem('wb_session') || 'null'); }catch(e){}
|
|
4267
|
+
if (!s || !s.t || Date.now() - s.t > 12 * 3600 * 1000) return false;
|
|
4268
|
+
// 页面上已经有内容(比如刚从草稿载入)就不动它
|
|
4269
|
+
if ($('#topic').value.trim() || $('#docBody').value.trim() || state.items.length) return false;
|
|
4270
|
+
// 恢复出来的内容跟绑定的那份不是同一篇,就别再往那份上写了
|
|
4271
|
+
if (state.draftId && !keysCompatible(state.draftKey, draftKeyOf(s))) bindDraft(null, '');
|
|
4272
|
+
applySnapshot(s);
|
|
4273
|
+
toast('已恢复上次没做完的内容');
|
|
4274
|
+
return true;
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
/* 一份草稿的身份键:优先选题,没写选题就用标题。两样都空说明还没成文,不算同一篇。 */
|
|
4278
|
+
function draftKeyOf(o){
|
|
4279
|
+
return String((o && (o.topic || o.title)) || '').trim();
|
|
4280
|
+
}
|
|
4281
|
+
|
|
4282
|
+
function bindDraft(id, key){
|
|
4283
|
+
state.draftId = id || null;
|
|
4284
|
+
state.draftKey = String(key || '').trim();
|
|
4285
|
+
try{
|
|
4286
|
+
id ? localStorage.setItem('wb_draft_id', id) : localStorage.removeItem('wb_draft_id');
|
|
4287
|
+
id ? localStorage.setItem('wb_draft_key', state.draftKey) : localStorage.removeItem('wb_draft_key');
|
|
4288
|
+
}catch(e){}
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
/* ---------- 自动暂存 ----------
|
|
4292
|
+
内容一改,1.5 秒没有新操作就落成草稿;切走页面时再兜一次。
|
|
4293
|
+
规则只有一条:还是同一篇就更新那份,真换了篇才另起一份。
|
|
4294
|
+
「换没换篇」交给 keysCompatible 判,选题改两个字不至于多出一堆草稿。 */
|
|
4295
|
+
const AUTOSAVE_MS = 1500;
|
|
4296
|
+
let _autoT = null; // 自动暂存的 debounce 定时器
|
|
4297
|
+
let _autoJson = ''; // 上一次成功落盘的内容指纹,用来判断还值不值得再发一次
|
|
4298
|
+
let _saveLock = Promise.resolve();
|
|
4299
|
+
|
|
4300
|
+
function contentJson(){
|
|
4301
|
+
try{ return JSON.stringify(draftSnapshot()); }catch(e){ return ''; }
|
|
4302
|
+
}
|
|
4303
|
+
|
|
4304
|
+
function hhmm(){
|
|
4305
|
+
return new Date().toLocaleTimeString('zh-CN', {hour:'2-digit', minute:'2-digit'});
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4308
|
+
function setSaveState(kind, text){
|
|
4309
|
+
const el = $('#saveState'); if (!el) return;
|
|
4310
|
+
el.className = 'save-state' + (kind ? ' ' + kind : '');
|
|
4311
|
+
el.textContent = text || '';
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4314
|
+
/* 刚载入 / 刚恢复的内容不算「改动」:只记指纹,别急着往草稿里写一遍 */
|
|
4315
|
+
function markContentClean(){
|
|
4316
|
+
_autoJson = contentJson();
|
|
4317
|
+
clearTimeout(_autoT);
|
|
4318
|
+
setSaveState('', '');
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4321
|
+
/* 选题改几个字还算同一篇:互为子串,或者首尾有足够长的公共部分 */
|
|
4322
|
+
function keysCompatible(a, b){
|
|
4323
|
+
a = String(a || '').trim(); b = String(b || '').trim();
|
|
4324
|
+
if (!a || !b) return true; // 空键不判,当成同一篇继续写
|
|
4325
|
+
if (a === b || a.includes(b) || b.includes(a)) return true;
|
|
4326
|
+
const n = Math.min(a.length, b.length);
|
|
4327
|
+
let p = 0; while (p < n && a[p] === b[p]) p++;
|
|
4328
|
+
let s = 0; while (s < n - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++;
|
|
4329
|
+
return Math.max(p, s) >= Math.max(3, Math.ceil(n / 2));
|
|
4330
|
+
}
|
|
4331
|
+
|
|
4332
|
+
function withSave(fn){
|
|
4333
|
+
const run = _saveLock.then(fn, fn);
|
|
4334
|
+
_saveLock = run.catch(()=>{});
|
|
4335
|
+
return run;
|
|
4336
|
+
}
|
|
4337
|
+
|
|
4338
|
+
async function persistDraft(opts){
|
|
4339
|
+
const o = opts || {};
|
|
4340
|
+
if (o.manual) state.demoPending = false; // 手动点了「暂存」就是要存,别被示例标记挡住
|
|
4341
|
+
if (!o.manual && state.demoPending) return null; // 随机示例不落草稿
|
|
4342
|
+
const d = draftSnapshot();
|
|
4343
|
+
const key = draftKeyOf(d);
|
|
4344
|
+
const empty = !key && !d.body.trim() && !d.items.length;
|
|
4345
|
+
if (empty){
|
|
4346
|
+
if (o.manual){ toast('左边还什么都没有,先写点内容'); $('#topic').focus(); }
|
|
4347
|
+
else setSaveState('', '');
|
|
4348
|
+
return null;
|
|
4349
|
+
}
|
|
4350
|
+
// 「另存当前为新草稿」一定新建;其余情况只要还像同一篇,就写回绑定那份
|
|
4351
|
+
const same = !o.asNew && !!state.draftId && keysCompatible(state.draftKey, key);
|
|
4352
|
+
const id = same ? state.draftId : null;
|
|
4353
|
+
if (!o.manual) setSaveState('', same ? '保存中…' : '新建草稿…');
|
|
4354
|
+
try{
|
|
4355
|
+
const r = await fetch('/api/drafts', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
4356
|
+
body: JSON.stringify(Object.assign({}, d, id ? {id} : {}))});
|
|
4357
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
4358
|
+
const j = await r.json();
|
|
4359
|
+
const created = !id || j.id !== id;
|
|
4360
|
+
bindDraft(j.id, key);
|
|
4361
|
+
_autoJson = contentJson();
|
|
4362
|
+
if (o.manual){
|
|
4363
|
+
refreshDraftCount();
|
|
4364
|
+
const n = (d.items || []).length;
|
|
4365
|
+
const at = j.updated ? new Date(j.updated * 1000).toLocaleTimeString('zh-CN', {hour:'2-digit', minute:'2-digit'}) : '';
|
|
4366
|
+
toast(same
|
|
4367
|
+
? `已更新这份草稿 · ${n} 条分镜 · ${at}`
|
|
4368
|
+
: `已存为新草稿 · ${n} 条分镜 · ${at}(上一篇没动)`);
|
|
4369
|
+
} else {
|
|
4370
|
+
setSaveState('ok', '已存 ' + hhmm());
|
|
4371
|
+
if (created) refreshDraftCount();
|
|
4372
|
+
}
|
|
4373
|
+
return j;
|
|
4374
|
+
}catch(e){
|
|
4375
|
+
if (o.manual){ toast('暂存失败:' + e.message); return null; }
|
|
4376
|
+
setSaveState('err', '没存上 · 5 秒后重试');
|
|
4377
|
+
clearTimeout(_autoT);
|
|
4378
|
+
_autoT = setTimeout(()=> autoSave(true), 5000);
|
|
4379
|
+
return null;
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
|
|
4383
|
+
function autoSave(force){
|
|
4384
|
+
// 内容和上次落盘的一模一样就不再发一次;force 用于失败重试
|
|
4385
|
+
if (force !== true && contentJson() === _autoJson) return Promise.resolve(null);
|
|
4386
|
+
return withSave(()=> persistDraft({}));
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4389
|
+
/* 所有「内容变了」的路径都往这里汇:手输、AI 生成、载入后修改、分镜增删 */
|
|
4390
|
+
function noteContentChanged(){
|
|
4391
|
+
// 随机示例不算用户内容:不排自动暂存,省得草稿箱里冒出一堆没用的示例
|
|
4392
|
+
if (state.demoPending) return;
|
|
4393
|
+
if (contentJson() === _autoJson) return;
|
|
4394
|
+
setSaveState('', '待保存…');
|
|
4395
|
+
clearTimeout(_autoT);
|
|
4396
|
+
_autoT = setTimeout(()=> autoSave(), AUTOSAVE_MS);
|
|
4397
|
+
}
|
|
4398
|
+
|
|
4399
|
+
/* 切走页面 / 切到后台:立刻补一次,不等那 1.5 秒 */
|
|
4400
|
+
let _leaveJson = '';
|
|
4401
|
+
function flushOnLeave(){
|
|
4402
|
+
clearTimeout(_autoT);
|
|
4403
|
+
saveSession(); // 本地兜底,同步写,切回来还在
|
|
4404
|
+
if (state.demoPending) return; // 随机示例不往草稿箱塞
|
|
4405
|
+
const d = draftSnapshot();
|
|
4406
|
+
const key = draftKeyOf(d);
|
|
4407
|
+
if (!key && !d.body.trim() && !d.items.length) return;
|
|
4408
|
+
const json = contentJson();
|
|
4409
|
+
if (json === _autoJson || json === _leaveJson) return; // 已经存过 / 这次离开已发过
|
|
4410
|
+
let id = (!!state.draftId && keysCompatible(state.draftKey, key)) ? state.draftId : null;
|
|
4411
|
+
// 首次保存就碰上离开:先把 id 定下来写进 localStorage,
|
|
4412
|
+
// 不然下一次打开没有绑定,又会拿同样的内容建一份重复草稿
|
|
4413
|
+
if (!id){
|
|
4414
|
+
id = 'd' + Date.now().toString(16).slice(-6) + Math.random().toString(16).slice(2, 6);
|
|
4415
|
+
bindDraft(id, key);
|
|
4416
|
+
}
|
|
4417
|
+
const body = JSON.stringify(Object.assign({}, d, {id}));
|
|
4418
|
+
_leaveJson = json;
|
|
4419
|
+
try{
|
|
4420
|
+
const blob = new Blob([body], {type: 'application/json'});
|
|
4421
|
+
if (navigator.sendBeacon && navigator.sendBeacon('/api/drafts', blob)) return;
|
|
4422
|
+
}catch(e){}
|
|
4423
|
+
try{
|
|
4424
|
+
fetch('/api/drafts', {method:'POST', keepalive: true,
|
|
4425
|
+
headers: {'Content-Type': 'application/json'}, body});
|
|
4426
|
+
}catch(e){}
|
|
4427
|
+
}
|
|
4428
|
+
|
|
4429
|
+
async function saveDraft(asNew){
|
|
4430
|
+
await withSave(()=> persistDraft({manual: true, asNew: !!asNew}));
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
async function refreshDraftCount(){
|
|
4434
|
+
try{
|
|
4435
|
+
const d = await (await fetch('/api/drafts')).json();
|
|
4436
|
+
$('#draftCount').textContent = d.total || 0;
|
|
4437
|
+
}catch(e){}
|
|
4438
|
+
const b = $('#saveDraft');
|
|
4439
|
+
if (b) b.title = state.draftId
|
|
4440
|
+
? `内容会自动暂存;点这里是立刻存一次。同一篇会更新「${state.draftKey || '当前草稿'}」,换了选题才另存一份`
|
|
4441
|
+
: '内容会自动暂存;点这里是立刻存一次(左边有内容时才会存)';
|
|
4442
|
+
}
|
|
4443
|
+
|
|
4444
|
+
/* 点「批量出图」= 这份草稿的活干完了:从草稿箱移走,别让它一直堆在待办里。
|
|
4445
|
+
内容不会丢——prompt / 正文都随这一批写进了历史,需要时从「历史记录」载入即可。 */
|
|
4446
|
+
async function consumeCurrentDraft(){
|
|
4447
|
+
const id = state.draftId;
|
|
4448
|
+
if (!id) return false;
|
|
4449
|
+
try{
|
|
4450
|
+
const r = await fetch('/api/drafts/' + encodeURIComponent(id), {method: 'DELETE'});
|
|
4451
|
+
if (!r.ok) return false;
|
|
4452
|
+
}catch(e){ return false; }
|
|
4453
|
+
bindDraft(null, '');
|
|
4454
|
+
// 内容已经出过图,别再当成「待保存」又自动建一份新草稿
|
|
4455
|
+
markContentClean();
|
|
4456
|
+
refreshDraftCount();
|
|
4457
|
+
if ($('#draftsDlg').open) openDrafts();
|
|
4458
|
+
return true;
|
|
4459
|
+
}
|
|
4460
|
+
|
|
4461
|
+
async function openDrafts(){
|
|
4462
|
+
const box = $('#draftList');
|
|
4463
|
+
box.innerHTML = '<div class="task-empty">加载中…</div>';
|
|
4464
|
+
if (!$('#draftsDlg').open) $('#draftsDlg').showModal();
|
|
4465
|
+
let d;
|
|
4466
|
+
try{ d = await (await fetch('/api/drafts')).json(); }
|
|
4467
|
+
catch(e){ box.innerHTML = '<div class="task-empty">草稿加载失败</div>'; return; }
|
|
4468
|
+
$('#draftCount').textContent = d.total || 0;
|
|
4469
|
+
$('#draftEmpty').hidden = !!d.items.length;
|
|
4470
|
+
box.innerHTML = '';
|
|
4471
|
+
d.items.forEach(it=>{
|
|
4472
|
+
const row = document.createElement('div');
|
|
4473
|
+
row.className = 'draft-row';
|
|
4474
|
+
/* 小字只留「N 条分镜 · 标题开头」,超出按码点截断加省略号;
|
|
4475
|
+
不再把 titles 又拼一遍(封面标题常和文章标题重复,会把小字拖得极长) */
|
|
4476
|
+
const brief = safeText(it.title) || safeText((it.titles || []).find(Boolean) || '');
|
|
4477
|
+
const sub = clipText([it.count ? it.count + ' 条分镜' : '无分镜',
|
|
4478
|
+
brief ? clipText(brief, 12) : ''].filter(Boolean).join(' · '), 30);
|
|
4479
|
+
const draftLabel = () => safeText(it.name || it.topic || it.title || '(无选题)');
|
|
4480
|
+
row.innerHTML = `<span class="dc"><b data-role="label">${esc(draftLabel())}</b>`
|
|
4481
|
+
+ `<input class="rn-input" data-role="rn" type="text" maxlength="60" placeholder="输入草稿名称,留空恢复默认" style="display:none">`
|
|
4482
|
+
+ `<span class="ds">${esc(fmtTime(it.updated))} · ${esc(sub)}</span></span>`
|
|
4483
|
+
+ (state.draftId === it.id ? '<span class="chipx on">使用中</span>' : '')
|
|
4484
|
+
+ '<span class="dacts">'
|
|
4485
|
+
+ '<button class="btn ghost mini" data-act="rename" type="button">重命名</button>'
|
|
4486
|
+
+ '<button class="btn ghost mini" data-act="load" type="button">载入</button>'
|
|
4487
|
+
+ '<button class="btn ghost mini" data-act="del" type="button">删除</button>'
|
|
4488
|
+
+ '</span>';
|
|
4489
|
+
const labelEl = row.querySelector('[data-role="label"]');
|
|
4490
|
+
const rnEl = row.querySelector('[data-role="rn"]');
|
|
4491
|
+
/* 回显「当前这个名字」:自定义名优先,没有自定义名就回显它现在显示的默认名
|
|
4492
|
+
(选题 / 标题),用户在现有名字上改,而不是对着一个空框重想 */
|
|
4493
|
+
const defaultName = () => safeText(it.topic || it.title || '');
|
|
4494
|
+
const currentName = () => safeText(it.name || '') || defaultName();
|
|
4495
|
+
rnEl.value = currentName();
|
|
4496
|
+
row.querySelector('[data-act="rename"]').onclick = ()=>{
|
|
4497
|
+
labelEl.style.display = 'none';
|
|
4498
|
+
rnEl.style.display = 'block';
|
|
4499
|
+
rnEl.value = currentName(); // 每次进来都回显最新名字
|
|
4500
|
+
rnEl.focus();
|
|
4501
|
+
// 光标放末尾,不是全选:全选后再打字会把原名整段冲掉,等于让人重打一遍
|
|
4502
|
+
try{ rnEl.setSelectionRange(rnEl.value.length, rnEl.value.length); }catch(e){}
|
|
4503
|
+
let done = false;
|
|
4504
|
+
const finish = async (save)=>{
|
|
4505
|
+
if (done) return;
|
|
4506
|
+
done = true;
|
|
4507
|
+
rnEl.style.display = 'none';
|
|
4508
|
+
labelEl.style.display = 'block';
|
|
4509
|
+
if (!save) { rnEl.value = currentName(); return; }
|
|
4510
|
+
const val = Array.from(safeText(rnEl.value.trim())).slice(0, 60).join('');
|
|
4511
|
+
/* 改完正好等于默认名(选题 / 标题)就存空串,让名字继续跟着选题走,
|
|
4512
|
+
免得把它写死成一份静态名称 */
|
|
4513
|
+
const nextName = val === defaultName() ? '' : val;
|
|
4514
|
+
if (nextName === (it.name || '')) { rnEl.value = currentName(); return; }
|
|
4515
|
+
try{
|
|
4516
|
+
const r = await fetch('/api/drafts/' + it.id, {method:'PATCH',
|
|
4517
|
+
headers:{'Content-Type':'application/json'}, body: JSON.stringify({name: nextName})});
|
|
4518
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
4519
|
+
it.name = nextName;
|
|
4520
|
+
labelEl.textContent = draftLabel();
|
|
4521
|
+
rnEl.value = currentName();
|
|
4522
|
+
toast(nextName ? '已重命名草稿' : '已恢复默认标题');
|
|
4523
|
+
}catch(e){ rnEl.value = currentName(); toast('重命名失败:' + e.message); }
|
|
4524
|
+
};
|
|
4525
|
+
rnEl.onkeydown = e=>{
|
|
4526
|
+
if (e.key === 'Enter'){ e.preventDefault(); finish(true); }
|
|
4527
|
+
else if (e.key === 'Escape'){ e.preventDefault(); finish(false); }
|
|
4528
|
+
};
|
|
4529
|
+
rnEl.onblur = ()=> finish(true);
|
|
4530
|
+
};
|
|
4531
|
+
row.querySelector('[data-act="load"]').onclick = async ()=>{
|
|
4532
|
+
const has = $('#topic').value.trim() || $('#docTitle').value.trim() || $('#docBody').value.trim() || state.items.length;
|
|
4533
|
+
if (has && !confirm('载入草稿会覆盖左侧当前内容,继续?')) return;
|
|
4534
|
+
try{
|
|
4535
|
+
const full = await (await fetch('/api/drafts/' + it.id)).json();
|
|
4536
|
+
applyDraft(full);
|
|
4537
|
+
$('#draftsDlg').close();
|
|
4538
|
+
toast('已载入草稿');
|
|
4539
|
+
}catch(e){ toast('载入失败'); }
|
|
4540
|
+
};
|
|
4541
|
+
row.querySelector('[data-act="del"]').onclick = async ()=>{
|
|
4542
|
+
if (!confirm('删除这份草稿?')) return;
|
|
4543
|
+
await fetch('/api/drafts/' + it.id, {method:'DELETE'});
|
|
4544
|
+
// 删掉的可能正是当前绑定的那份,赶紧断绑,不然下次自动暂存又把它建回来
|
|
4545
|
+
if (state.draftId === it.id) bindDraft(null, '');
|
|
4546
|
+
row.remove();
|
|
4547
|
+
refreshDraftCount();
|
|
4548
|
+
toast('已删除');
|
|
4549
|
+
};
|
|
4550
|
+
box.appendChild(row);
|
|
4551
|
+
});
|
|
4552
|
+
}
|
|
4553
|
+
|
|
4554
|
+
$('#saveDraft').onclick = ()=> saveDraft(false);
|
|
4555
|
+
$('#openDrafts').onclick = openDrafts;
|
|
4556
|
+
$('#draftsClose').onclick = ()=> $('#draftsDlg').close();
|
|
4557
|
+
$('#draftNew').onclick = ()=>{ $('#draftsDlg').close(); saveDraft(true); };
|
|
4558
|
+
|
|
4559
|
+
$('#openMonitor').onclick = openMonitor;
|
|
4560
|
+
$('#stopGen').onclick = stopAllJobs;
|
|
4561
|
+
$('#monStop').onclick = stopAllJobs;
|
|
4562
|
+
$('#monClose').onclick = closeMonitor;
|
|
4563
|
+
$('#monitorDlg').addEventListener('close', ()=>{ clearTimeout(monTimer); monTimer = null; });
|
|
4564
|
+
$('#monRefresh').onclick = async ()=>{
|
|
4565
|
+
await fetch('/api/pool/refresh', {method:'POST'});
|
|
4566
|
+
await refreshMonitor(); refreshPool();
|
|
4567
|
+
};
|
|
4568
|
+
$('#monReset').onclick = async ()=>{
|
|
4569
|
+
await fetch('/api/pool/reset', {method:'POST'});
|
|
4570
|
+
await refreshMonitor(); refreshPool();
|
|
4571
|
+
toast('已重置账号状态');
|
|
4572
|
+
};
|
|
4573
|
+
$('#monCheck').onclick = async ()=>{
|
|
4574
|
+
$('#monCheck').disabled = true;
|
|
4575
|
+
try{ await fetch('/api/pool/check', {method:'POST'}); toast('开始检测全部账号,约 3-5 分钟'); }
|
|
4576
|
+
catch(e){ toast('检测启动失败'); }
|
|
4577
|
+
await refreshMonitor();
|
|
4578
|
+
};
|
|
4579
|
+
|
|
4580
|
+
/* 本标签页手动取消过勾选就尊重它,别刷新一下又勾回去 */
|
|
4581
|
+
try{
|
|
4582
|
+
const _fu = sessionStorage.getItem('wb_fusion_user');
|
|
4583
|
+
if (_fu !== null) $('#fusion').checked = (_fu === '1');
|
|
4584
|
+
}catch(e){}
|
|
4585
|
+
renderRedoBar();
|
|
4586
|
+
renderMoodChips();
|
|
4587
|
+
syncOmode();
|
|
4588
|
+
renderShootChips();
|
|
4589
|
+
syncInstr();
|
|
4590
|
+
syncRatioUi();
|
|
4591
|
+
renderItems();
|
|
4592
|
+
/* 窗口变宽变窄会重排换行,正文框的「两行上限」要跟着重算 */
|
|
4593
|
+
let _fitTimer = 0;
|
|
4594
|
+
window.addEventListener('resize', ()=>{
|
|
4595
|
+
clearTimeout(_fitTimer);
|
|
4596
|
+
_fitTimer = setTimeout(()=>{ document.querySelectorAll('.it-body').forEach(fitBody); }, 150);
|
|
4597
|
+
});
|
|
4598
|
+
loadCatalog().catch(e=>toast('目录加载失败:' + e.message))
|
|
4599
|
+
.finally(()=>{
|
|
4600
|
+
// 目录就绪后再恢复:风格/图型/配色的 id 要靠目录才对得上
|
|
4601
|
+
restoreSession();
|
|
4602
|
+
resumeBatch();
|
|
4603
|
+
markContentClean(); // 开场内容算干净,别把恢复出来的东西又存一遍
|
|
4604
|
+
});
|
|
4605
|
+
document.addEventListener('input', saveSessionSoon);
|
|
4606
|
+
/* 一旦真的动手改字,随机示例就变成用户自己的内容,恢复自动暂存 */
|
|
4607
|
+
document.addEventListener('input', ()=>{ state.demoPending = false; }, true);
|
|
4608
|
+
/* 切走 / 关标签 / 切到后台,先把没落盘的内容补存一次,别指望那 1.5 秒 */
|
|
4609
|
+
window.addEventListener('pagehide', flushOnLeave);
|
|
4610
|
+
window.addEventListener('beforeunload', flushOnLeave);
|
|
4611
|
+
document.addEventListener('visibilitychange', ()=>{
|
|
4612
|
+
if (document.visibilityState === 'hidden'){ flushOnLeave(); return; }
|
|
4613
|
+
// 出图跑完时人在别的标签页:气泡当时没弹出来,回来补一条
|
|
4614
|
+
if (_bgNotice){
|
|
4615
|
+
const n = _bgNotice; _bgNotice = null;
|
|
4616
|
+
toast(n.msg, Object.assign({}, n.opt, {force: true}));
|
|
4617
|
+
}
|
|
4618
|
+
});
|
|
4619
|
+
$('#saveState').onclick = ()=> saveDraft(false);
|
|
4620
|
+
refreshPool();
|
|
4621
|
+
loadAppName(); // 页头名字跟着配置走,不打开设置弹窗也生效
|
|
4622
|
+
refreshDraftCount();
|
|
4623
|
+
/* 旧版本只存了 draft_id,没记选题:补一次,免得升级后第一次暂存就多出一份 */
|
|
4624
|
+
if (state.draftId && !state.draftKey){
|
|
4625
|
+
fetch('/api/drafts/' + encodeURIComponent(state.draftId))
|
|
4626
|
+
.then(r=> r.ok ? r.json() : null)
|
|
4627
|
+
.then(d=>{
|
|
4628
|
+
if (d && state.draftId){
|
|
4629
|
+
bindDraft(state.draftId, draftKeyOf(d));
|
|
4630
|
+
refreshDraftCount();
|
|
4631
|
+
}
|
|
4632
|
+
})
|
|
4633
|
+
.catch(()=>{});
|
|
4634
|
+
}
|
|
4635
|
+
setInterval(refreshPool, 10000);
|
|
4636
|
+
|
|
4637
|
+
/* ---------- 弹窗打开时锁住背景滚动,滚轮不再穿透到下面的页面 ----------
|
|
4638
|
+
用 open 属性变化做同步,自动覆盖 showModal / close / ESC / 表单关闭,
|
|
4639
|
+
以及以后新增的弹窗,不用逐个改调用点。 */
|
|
4640
|
+
(()=>{
|
|
4641
|
+
const root = document.documentElement, body = document.body;
|
|
4642
|
+
let locked = false, savedPad = '';
|
|
4643
|
+
function sync(){
|
|
4644
|
+
const any = !!document.querySelector('dialog[open]');
|
|
4645
|
+
if (any === locked) return;
|
|
4646
|
+
locked = any;
|
|
4647
|
+
if (any){
|
|
4648
|
+
// 滚动条消失会让页面横向跳一下,补一段等宽 padding 抵消
|
|
4649
|
+
const gap = window.innerWidth - root.clientWidth;
|
|
4650
|
+
savedPad = body.style.paddingRight;
|
|
4651
|
+
if (gap > 0) body.style.paddingRight = ((parseFloat(savedPad) || 0) + gap) + 'px';
|
|
4652
|
+
root.classList.add('modal-lock');
|
|
4653
|
+
} else {
|
|
4654
|
+
root.classList.remove('modal-lock');
|
|
4655
|
+
body.style.paddingRight = savedPad;
|
|
4656
|
+
}
|
|
4657
|
+
}
|
|
4658
|
+
new MutationObserver(sync).observe(root,
|
|
4659
|
+
{subtree:true, attributes:true, attributeFilter:['open']});
|
|
4660
|
+
|
|
4661
|
+
// observer 是微任务,滚轮可能抢在前面;这里补同步并挡掉落在弹窗外的滚轮。
|
|
4662
|
+
// 只拦「弹窗外」的事件,弹窗内部原有的滚轮逻辑(大图缩放等)不受影响。
|
|
4663
|
+
const outside = e => {
|
|
4664
|
+
const dlg = document.querySelector('dialog[open]');
|
|
4665
|
+
if (!dlg){ sync(); return; }
|
|
4666
|
+
sync();
|
|
4667
|
+
if (!dlg.contains(e.target)) e.preventDefault();
|
|
4668
|
+
};
|
|
4669
|
+
document.addEventListener('wheel', outside, {passive:false, capture:true});
|
|
4670
|
+
document.addEventListener('touchmove', outside, {passive:false, capture:true});
|
|
4671
|
+
sync();
|
|
4672
|
+
})();
|
|
4673
|
+
</script>
|
|
4674
|
+
|
|
4675
|
+
<script>
|
|
4676
|
+
/* 顶部竖滚轮播提示 */
|
|
4677
|
+
(function(){
|
|
4678
|
+
var track = document.getElementById('adTrack');
|
|
4679
|
+
if(!track) return;
|
|
4680
|
+
var items = track.children.length;
|
|
4681
|
+
if(items < 2) return;
|
|
4682
|
+
track.appendChild(track.children[0].cloneNode(true)); // 克隆首条,衔接更顺
|
|
4683
|
+
var h = 20, idx = 0, HOLD = 5200, EASE = 720;
|
|
4684
|
+
setInterval(function(){
|
|
4685
|
+
idx++;
|
|
4686
|
+
track.style.transform = 'translateY(' + (-idx*h) + 'px)';
|
|
4687
|
+
if(idx === items){
|
|
4688
|
+
setTimeout(function(){
|
|
4689
|
+
track.style.transition = 'none';
|
|
4690
|
+
idx = 0;
|
|
4691
|
+
track.style.transform = 'translateY(0)';
|
|
4692
|
+
void track.offsetHeight; // 强制回流后恢复过渡
|
|
4693
|
+
track.style.transition = '';
|
|
4694
|
+
}, EASE);
|
|
4695
|
+
}
|
|
4696
|
+
}, HOLD);
|
|
4697
|
+
})();
|
|
4698
|
+
</script>
|
|
4699
|
+
|
|
4700
|
+
<script>
|
|
4701
|
+
/* 右上角「更多功能」下拉菜单 */
|
|
4702
|
+
(function(){
|
|
4703
|
+
var toggle = document.getElementById('moreToggle');
|
|
4704
|
+
var list = document.getElementById('moreList');
|
|
4705
|
+
var menu = document.getElementById('moreMenu');
|
|
4706
|
+
if(!toggle || !list || !menu) return;
|
|
4707
|
+
function close(){ list.hidden = true; toggle.setAttribute('aria-expanded', 'false'); }
|
|
4708
|
+
function open(){ list.hidden = false; toggle.setAttribute('aria-expanded', 'true'); }
|
|
4709
|
+
toggle.addEventListener('click', function(e){
|
|
4710
|
+
e.stopPropagation();
|
|
4711
|
+
list.hidden ? open() : close();
|
|
4712
|
+
});
|
|
4713
|
+
// 点菜单项后收起(历史/监控/号池各自的弹窗逻辑不变)
|
|
4714
|
+
list.addEventListener('click', function(){ close(); });
|
|
4715
|
+
// 点外部收起
|
|
4716
|
+
document.addEventListener('click', function(e){
|
|
4717
|
+
if(!list.hidden && !menu.contains(e.target)) close();
|
|
4718
|
+
});
|
|
4719
|
+
document.addEventListener('keydown', function(e){
|
|
4720
|
+
if(e.key === 'Escape' && !list.hidden) close();
|
|
4721
|
+
});
|
|
4722
|
+
})();
|
|
4723
|
+
</script>
|
|
4724
|
+
</body>
|
|
4725
|
+
</html>
|