@@ -77,9 +82,12 @@ export function LibraryPage() {
onDragOver={e => { if ([...e.dataTransfer.types].includes('Files')) { e.preventDefault(); setDrop(true); } }} onDragLeave={() => setDrop(false)}
onDrop={e => { e.preventDefault(); setDrop(false); upload(e.dataTransfer.files); }}>
setOpen(null)} onTag={t => addTag([cur.id], t)} tags={tags} />}
@@ -128,6 +136,25 @@ export function LibraryPage() {
);
}
+// 文件夹首页:每个故事一个,加上快速创作、上传了还没用、待整理。一个文件被多个故事用到,就出现在每个文件夹里,只存一份。
+function Folders({use, open}) {
+ const {state} = useStore();
+ const live = state.assets.filter(a => !a.inbox);
+ const pack = (key, title, Icon, list, sub) => ({key, title, Icon, list, sub, covers: list.filter(a => a.kind === 'image').slice(0, 4)});
+ const folders = [
+ ...state.stories.map(s => pack(s.id, s.title, Folder, live.filter(a => use.assetStories(a.id).has(s.id)), '故事')),
+ pack('quick', '快速创作', MessageSquare, live.filter(a => use.quick.has(a.id)), '所有快速创作用到的'),
+ pack('unused', '上传了还没用', Upload, live.filter(a => !use.assetStories(a.id).size && !use.quick.has(a.id)), '还没放进任何作品'),
+ pack('inbox', '待整理', Inbox, state.assets.filter(a => a.inbox), '刚上传,等你归类'),
+ ];
+ const n = (l, k) => l.filter(a => a.kind === k).length;
+ return {folders.map(f =>
open(f.key)}>
+ {f.covers.length ? f.covers.map(a =>
) :
}
+ {f.title}
+ {f.sub} · {f.list.length} 个文件{f.list.length ? ` · ${n(f.list, 'image')} 图 · ${n(f.list, 'video')} 视频 · ${n(f.list, 'audio')} 声音` : ''}
+ )}
;
+}
+
function FileInto({ids, onDone}) {
const {state, dispatch, toast} = useStore();
if (!ids.length) return null;
diff --git a/design/inkseq-v2-prototype/src/main.jsx b/design/inkseq-v2-prototype/src/main.jsx
index c796e629..9d9ff3b0 100644
--- a/design/inkseq-v2-prototype/src/main.jsx
+++ b/design/inkseq-v2-prototype/src/main.jsx
@@ -11,6 +11,7 @@ import {SettingsPage} from './pages.jsx';
import {GpuPage} from './gpu.jsx';
import {TrashPage} from './trashPage.jsx';
import {LibraryPage} from './library.jsx';
+import {AuthPage, LowCredits, RolePreview} from './account.jsx';
import {coverOf, planDrop, variantOf} from './model.js';
import './styles.css';
import {installHoldHint} from './holdhint.js';
@@ -51,6 +52,8 @@ function App() {
if (p === 'story') { const id = state.activeStory || state.stories[0]?.id; if (id) dispatch({type: 'openStory', id}); }
else if (p === 'quick') { const s = state.sessions.find(x => !x.trashedAt); s ? dispatch({type: 'qcOpen', id: s.id}) : dispatch({type: 'qcNew'}); }
else if (['home', 'library', 'gpu', 'settings', 'trash'].includes(p)) dispatch({type: 'page', page: p});
+ else if (p === 'auth') dispatch({type: 'role', role: 'guest'});
+ else if (p === 'credits' || p === 'admin') { if (p === 'admin') dispatch({type: 'role', role: 'admin'}); dispatch({type: 'settingsTab', tab: p}); }
}, []); // eslint-disable-line
// item = {asset: id} | {profile: id}
@@ -106,10 +109,12 @@ function App() {
return (
setDragging(null)}>
- {state.page === 'story' && state.activeStory ?
- : state.page === 'quick' ?
: state.page === 'gpu' ?
: state.page === 'trash' ?
: state.page === 'library' ?
: state.page === 'settings' ?
:
}
+ {state.role === 'guest' || state.page === 'auth' ?
: state.page === 'story' && state.activeStory ?
+ : state.page === 'quick' ?
: state.page === 'gpu' && state.role === 'admin' ?
: state.page === 'trash' ?
: state.page === 'library' ?
: state.page === 'settings' ?
:
}
{dragged ? : null}
+
+
原型 · 示例数据 · 生成和粗剪是模拟的 · 字幕和镜头清单可真实下载 · 不连服务器,不花钱
);
diff --git a/design/inkseq-v2-prototype/src/model.js b/design/inkseq-v2-prototype/src/model.js
index 2b838a12..aefefc54 100644
--- a/design/inkseq-v2-prototype/src/model.js
+++ b/design/inkseq-v2-prototype/src/model.js
@@ -173,11 +173,18 @@ export const shotControls = (shot, db) => ({...(db.state.genDefaults || {}), ...
// 生成前的粗估(原型):按精度、时长、分辨率估一条要多久、花多少;真实数字以算力页的机器价格为准。
const RES_FACTOR = {'480P': 1, '576P': 1.4, '768P': 2.2};
const HOURLY = {draft: 0.69, final: 1.29};
-export function estimate(list) {
- let min = 0, usd = 0;
+// Users pay in credits (1 credit = US$0.01 retail). Price = seconds of output video × the tier's rate;
+// the operator edits the rates (设置 → 管理员 → 定价) and the backend quotes them — never hard-coded there.
+// usd below is the internal machine-cost estimate, shown to admins only.
+export const CREDIT_USD = 0.01;
+export const DEFAULT_RATES = {fast: 2, draft: 4, final: 12}; // credits per second of video
+export const fmtCredits = n => `${Math.round(n).toLocaleString('en-US')} 积分`;
+export const creditsFor = ({quality = 'draft', duration = 5, count = 1}, rates = DEFAULT_RATES) => Math.ceil((rates[quality] ?? rates.draft) * duration) * count;
+export function estimate(list, rates = DEFAULT_RATES) {
+ let min = 0, usd = 0, credits = 0;
for (const {quality = 'draft', duration = 5, resolution = quality === 'final' ? '768P' : '480P', count = 1} of list) {
const m = (quality === 'final' ? 5 : 3) * (duration / 5) * (RES_FACTOR[resolution] || 1.6) * count;
- min += m; usd += m / 60 * HOURLY[quality];
+ min += m; usd += m / 60 * HOURLY[quality]; credits += creditsFor({quality, duration, count}, rates);
}
- return {min, usd, text: `约 ${min < 1 ? '1' : Math.round(min)} 分钟机器时间 · 约 $${usd < 0.01 ? '0.01' : usd.toFixed(2)}`};
+ return {min, usd, credits, text: `约 ${fmtCredits(credits)} · 约 ${min < 1 ? '1' : Math.round(min)} 分钟`};
}
diff --git a/design/inkseq-v2-prototype/src/pages.jsx b/design/inkseq-v2-prototype/src/pages.jsx
index 05d33d6d..e59ecd0b 100644
--- a/design/inkseq-v2-prototype/src/pages.jsx
+++ b/design/inkseq-v2-prototype/src/pages.jsx
@@ -1,5 +1,6 @@
import React, {useEffect, useState} from 'react';
-import {Bot, Trash2, Cpu, LayoutGrid, MessageSquare, Images, Settings2, KeyRound, LogOut, Play, Plus, Power, ShieldCheck, SlidersHorizontal, Square, Timer, UserRound, Wallet} from 'lucide-react';
+import {Bot, Coins, Trash2, Cpu, LayoutGrid, MessageSquare, Images, Settings2, KeyRound, LogOut, Play, Plus, Power, ShieldCheck, SlidersHorizontal, Square, Timer, UserRound, Wallet} from 'lucide-react';
+import {AdminPanel, CreditsChip, CreditsPanel, LinkedLogins} from './account.jsx';
import {AdvancedSheet, changedSummary} from './advanced.jsx';
import {MODELS} from './model.js';
import {queuePositions, useStore} from './store.jsx';
@@ -27,13 +28,14 @@ export function AppHeader() {
dispatch({type: 'page', page: 'home'})}>作品
state.sessions.some(x => x.id === state.activeSession && !x.trashedAt) ? dispatch({type: 'page', page: 'quick'}) : state.sessions.find(x => !x.trashedAt) ? dispatch({type: 'qcOpen', id: state.sessions.find(x => !x.trashedAt).id}) : dispatch({type: 'qcNew'})}>快速创作
dispatch({type: 'page', page: 'library'})}>素材库
- dispatch({type: 'page', page: 'gpu'})}>算力 {busy > 0 && {busy} }
+ {state.role === 'admin' && dispatch({type: 'page', page: 'gpu'})}>算力 {busy > 0 && {busy} } }
dispatch({type: 'page', page: 'settings'})}>设置
+
dispatch({type: 'page', page: 'trash'})}> {binned > 0 && {binned} }
- S
+ {state.role === 'admin' ? 'S' : 'M'}
);
}
@@ -41,10 +43,14 @@ export function AppHeader() {
/* ---------- 设置 ---------- */
export function SettingsPage({onToast}) {
const {state, dispatch} = useStore();
- const [tab, setTab] = useState('account'), [newToken, setNewToken] = useState(null), [name, setName] = useState('');
+ const [tab0, setTab] = useState('account'), [newToken, setNewToken] = useState(null), [name, setName] = useState('');
+ const admin = state.role === 'admin';
+ // 顶栏积分 / 积分不够 jump straight to a tab.
+ useEffect(() => { if (state.settingsTab) { setTab(state.settingsTab); dispatch({type: 'settingsTab', tab: null}); } }, [state.settingsTab]); // eslint-disable-line
+ const tab = tab0 === 'admin' && !admin ? 'account' : tab0;
const ago = t => (t ? `${Math.max(1, Math.round((Date.now() - t) / 3600e3))} 小时前` : '还没用过');
const [advOpen, setAdvOpen] = useState(false), gd = state.genDefaults || {};
- const tabs = [['account', '账号', UserRound], ['gen', '生成默认值', SlidersHorizontal], ['agent', 'AI 助手', Bot], ['budget', '我的额度', Wallet], ['admin', '管理员', ShieldCheck]];
+ const tabs = [['account', '账号', UserRound], ['credits', '我的积分', Coins], ['gen', '生成默认值', SlidersHorizontal], ['agent', 'AI 助手', Bot], ...(admin ? [['admin', '管理员', ShieldCheck]] : [])];
return (
@@ -53,12 +59,11 @@ export function SettingsPage({onToast}) {
{tab === 'account' && <>
账号
- S superdan · 管理员 用户名登录 · 内测账号
-
- onToast('原型:不会真的退出')}> 退出登录
+
+
+ dispatch({type: 'role', role: 'guest'})}> 退出登录
+ 注销账号:作品和积分记录保留 30 天后删除,期间可以撤回。 onToast('原型:会先发确认邮件')}>申请注销
>}
{tab === 'gen' && <>
生成默认值
@@ -69,8 +74,8 @@ export function SettingsPage({onToast}) {
{advOpen && dispatch({type: 'genDefaults', value: x})} onClose={() => setAdvOpen(false)} />}
>}
{tab === 'agent' && }
- {tab === 'budget' && }
- {tab === 'admin' && }
+ {tab === 'credits' && }
+ {tab === 'admin' && admin && } />}
@@ -86,25 +91,9 @@ function LimitField({label, hint, k, unit = '$', step = 1, min = 0, options}) {
: {unit === '$' && '$'} dispatch({type: 'limits', patch: {[k]: Math.max(min, +e.target.value || 0)}})} />{unit !== '$' && unit} };
}
-function LimitsPanel() {
- const {state} = useStore();
- const L = state.limits, pct = L.daily ? Math.min(100, state.spentToday / L.daily * 100) : 0;
- return <>
- 我的额度
- 这些都由你自己定,随时可以改。到了上限,新的生成会先停下来问你,正在跑的不受影响。
- 今天已花 ${state.spentToday.toFixed(2)}{L.daily ? ` / $${L.daily}` : '(不限)'}
-
-
-
-
- AI 助手的每日上限在「AI 助手」里,每个助手单独设。
- >;
-}
-
function AdminLimits() {
return <>
- 管理员
- 只有管理员能看到这一页。这里的数都是默认值,改了立刻生效;不会有写死在后台、没地方改的限制。
+ 这里的数都是默认值,改了立刻生效;不会有写死在后台、没地方改的限制。机器由系统按排队自动开关,普通用户不能开机。
测试机(开发和验收用)
diff --git a/design/inkseq-v2-prototype/src/store.jsx b/design/inkseq-v2-prototype/src/store.jsx
index 3a1424e1..f9bd03fc 100644
--- a/design/inkseq-v2-prototype/src/store.jsx
+++ b/design/inkseq-v2-prototype/src/store.jsx
@@ -2,7 +2,7 @@ import React, {createContext, useCallback, useContext, useEffect, useMemo, useRe
import {expire, liveView, purge, setTrash} from './trash.js';
import {getLang, onLang, translateData} from './i18n.js';
import {BLANK_SHOT, CLIPS, DEFAULT_SETTINGS, mockUploads, parseScript, seedAssets, seedProfiles, seedQuickChats, seedShots, seedStories} from './data.js';
-import {makeDb, modeOf, MODELS, planDrop, RECIPE_KEYS} from './model.js';
+import {creditsFor, DEFAULT_RATES, makeDb, modeOf, MODELS, planDrop, RECIPE_KEYS} from './model.js';
const KEY = 'story-prototype-v8';
const initial = () => ({
@@ -48,7 +48,30 @@ function seedTags(s) {
}
// Older saved demos have no agents yet.
// Every limit is a setting (with a default) the owner/admin can change in 设置 — nothing is hard-coded.
-export const DEFAULT_LIMITS = {daily: 20, confirmAbove: 1, testDaily: 20, maxHourly: 1.5, idleStopMin: 10, maxRunHours: 2};
+// daily / confirmAbove are in credits (users never see dollars); the test-machine limits stay in US$ (admin only).
+export const DEFAULT_LIMITS = {daily: 2000, confirmAbove: 100, testDaily: 20, maxHourly: 1.5, idleStopMin: 10, maxRunHours: 2};
+// Credit packs sold through Stripe Checkout. Bigger packs get a small bonus; the backend owns this list.
+export const PACKS = [
+ {id: 'starter', name: '入门', usd: 5, credits: 500, bonus: 0},
+ {id: 'creator', name: '创作者', usd: 20, credits: 2100, bonus: 5},
+ {id: 'studio', name: '工作室', usd: 50, credits: 5500, bonus: 10},
+ {id: 'pro', name: '专业', usd: 100, credits: 11500, bonus: 15},
+];
+const DAY = 24 * 3600e3;
+const seedCredits = () => ({balance: 1240, held: 0, entries: [
+ {id: 'ce1', at: Date.now() - 3 * DAY, kind: 'grant', amount: 100, label: '新用户赠送(邮箱已验证)'},
+ {id: 'ce2', at: Date.now() - 2 * DAY, kind: 'purchase', amount: 2100, label: '充值 · 创作者包', usd: 20},
+ {id: 'ce3', at: Date.now() - DAY, kind: 'spend', amount: -960, label: '生成 · 最后一班来信(12 条)'},
+ {id: 'ce4', at: Date.now() - DAY + 3600e3, kind: 'refund', amount: 20, label: '退回 · 生成失败的 1 条'},
+ {id: 'ce5', at: Date.now() - 5 * 3600e3, kind: 'spend', amount: -20, label: '生成 · 快速创作「雨夜站台」'},
+]});
+// Admin view of accounts (prototype sample). Real data comes from GET /v1/admin/users.
+const seedUsers = () => [
+ {id: 'u1', name: 'superdan', email: 'dan@inkseq.com', via: ['密码'], role: 'admin', status: 'active', balance: 1240, spent30: 960, joined: Date.now() - 20 * DAY},
+ {id: 'u2', name: 'supervan', email: 'van@inkseq.com', via: ['密码'], role: 'user', status: 'active', balance: 380, spent30: 2140, joined: Date.now() - 15 * DAY},
+ {id: 'u3', name: 'Mia Chen', email: 'mia.c@gmail.com', via: ['Google'], role: 'user', status: 'active', balance: 100, spent30: 0, joined: Date.now() - 2 * DAY},
+ {id: 'u4', name: 'k.tanaka', email: 'k.tanaka@icloud.com', via: ['Apple', '邮箱'], role: 'user', status: 'suspended', balance: 0, spent30: 40, joined: Date.now() - 6 * DAY},
+];
// Older saves have portraits with baked-in Chinese text; strip it so thumbnails are language-neutral.
const plainPortrait = th => {
if (typeof th !== 'string' || !th.startsWith('data:image/svg+xml')) return th;
@@ -56,7 +79,11 @@ const plainPortrait = th => {
if (!svg.includes(']*>[^<]*<\/text>/g, '').replace(/]*\/>/g, ''));
};
-const normalize = s => ({...s, assets: (s.assets || []).map(a => ({...a, thumb: plainPortrait(a.thumb)})), agents: s.agents || [], agentInvite: s.agentInvite || null, limits: {...DEFAULT_LIMITS, ...(s.limits || {})}, spentToday: s.spentToday ?? 3.42});
+const creditLimits = l => (l && !l.inCredits ? {...l, daily: (l.daily ?? 20) * 100, confirmAbove: (l.confirmAbove ?? 1) * 100, inCredits: true} : l);
+const normalize = s => ({...s, assets: (s.assets || []).map(a => ({...a, thumb: plainPortrait(a.thumb)})), agents: s.agents || [], agentInvite: s.agentInvite || null,
+ limits: {...DEFAULT_LIMITS, inCredits: true, ...(creditLimits(s.limits) || {})}, creditsToday: s.creditsToday ?? 340,
+ role: s.role || 'user', credits: s.credits || seedCredits(), rates: s.rates || {...DEFAULT_RATES}, ratesRev: s.ratesRev || 3,
+ users: s.users || seedUsers(), adminLog: s.adminLog || [], overhead: s.overhead ?? 3, creditBlock: null});
function load() {
try { const raw = localStorage.getItem(KEY); if (raw) return normalize(seedTags(expire(JSON.parse(raw)))); } catch {}
return normalize(seedTags(initial()));
@@ -84,9 +111,9 @@ function enqueue(state, shot, quality, vary) {
recipe: Object.fromEntries(RECIPE_KEYS.map(k => [k, k === 'mode' ? modeOf(shot) : shot[k]]))}]};
}
-function reducer(state, a) {
+function core(state, a) {
switch (a.type) {
- case 'reset': return initial();
+ case 'reset': return normalize(seedTags(initial()));
case 'page': return {...state, page: a.page};
case 'genDefaults': return {...state, genDefaults: a.value};
// 故事默认:存在故事上,镜头没改过的项自动跟随;不写进每个镜头,所以不会覆盖镜头自己的修改。时长属于镜头内容,不进默认。
@@ -111,15 +138,15 @@ function reducer(state, a) {
return {...state, agents: [...state.agents, ag]};
}
case 'agentPause': return {...state, agents: state.agents.map(x => (x.id === a.id ? {...x, paused: !x.paused, log: [...x.log, {at: Date.now(), text: x.paused ? '你恢复了它' : '你暂停了它'}]} : x))};
- case 'agentCap': return {...state, agents: state.agents.map(x => (x.id === a.id ? {...x, cap: a.cap, log: [...x.log, {at: Date.now(), text: `你把每日上限改成 $${a.cap}`}]} : x))};
+ case 'agentCap': return {...state, agents: state.agents.map(x => (x.id === a.id ? {...x, cap: a.cap, log: [...x.log, {at: Date.now(), text: `你把每日上限改成 ${a.cap * 100} 积分`}]} : x))};
case 'limits': return {...state, limits: {...state.limits, ...a.patch}};
case 'agentRevoke': return {...state, agents: state.agents.filter(x => x.id !== a.id)};
// 原型:模拟助手在它的范围里做一次事(2 条草稿),并记账;超出每日上限就不提交。
case 'agentDemo': {
const ag = state.agents.find(x => x.id === a.id); if (!ag || ag.paused) return state;
- const cost = 0.06, logIt = (s, text, spent = 0) => ({...s, agents: s.agents.map(x => (x.id === ag.id ? {...x, spent: x.spent + spent, log: [...x.log, {at: Date.now(), text}]} : x))});
+ const cost = 0.4, logIt = (s, text, spent = 0) => ({...s, agents: s.agents.map(x => (x.id === ag.id ? {...x, spent: x.spent + spent, log: [...x.log, {at: Date.now(), text}]} : x))});
if (ag.perm !== 'generate') return logIt(state, '整理了提示词(只看权限,没有生成)');
- if (ag.spent + cost > ag.cap) return logIt(state, `想生成 2 条,但会超过每日上限 $${ag.cap},没有提交`);
+ if (ag.spent + cost > ag.cap) return logIt(state, `想生成 2 条,但会超过每日上限 ${ag.cap * 100} 积分,没有提交`);
const prompt = ['雨夜站台,女孩回头,霓虹倒影', '清晨车站,老陈擦眼镜,阳光斜照', '林岚拆开信,手微微发抖,近景'][state.agents.indexOf(ag) % 3 + (ag.log.length % 2)] || '雨夜站台,女孩回头';
if (ag.scope.kind === 'story') {
const st = state.stories.find(x => x.id === ag.scope.id), ch = st?.chapters[0], sc = ch?.scenes[0];
@@ -443,10 +470,52 @@ function reducer(state, a) {
return {...state, profiles: [...state.profiles, copy], shots, openProfile: id};
}
case 'openProfile': return {...state, openProfile: a.id};
+ // ---- accounts, credits, admin (prototype of the launch batch A) ----
+ case 'role': return {...state, role: a.role, page: a.role === 'guest' ? 'auth' : state.page === 'auth' ? 'home' : (a.role === 'user' && state.page === 'gpu' ? 'home' : state.page)};
+ case 'signIn': return {...state, role: 'user', page: 'home'};
+ case 'settingsTab': return {...state, page: 'settings', settingsTab: a.tab};
+ case 'creditBlockClose': return {...state, creditBlock: null};
+ case 'topUp': {
+ const p = PACKS.find(x => x.id === a.pack); if (!p) return state;
+ const entry = {id: uid('ce'), at: Date.now(), kind: 'purchase', amount: p.credits, label: `充值 · ${p.name}包`, usd: p.usd};
+ return {...state, creditBlock: null, credits: {...state.credits, balance: state.credits.balance + p.credits, entries: [...state.credits.entries, entry]}};
+ }
+ case 'adminCredits': {
+ const u = state.users.find(x => x.id === a.user); if (!u || !a.delta || !a.reason) return state;
+ const log = {at: Date.now(), text: `${a.delta > 0 ? '给' : '扣'} ${u.name} ${Math.abs(a.delta)} 积分 · 原因:${a.reason}`};
+ return {...state, users: state.users.map(x => (x.id === a.user ? {...x, balance: Math.max(0, x.balance + a.delta)} : x)), adminLog: [...state.adminLog, log]};
+ }
+ case 'adminUser': {
+ const u = state.users.find(x => x.id === a.user); if (!u) return state;
+ const what = a.patch.role ? `角色改为${a.patch.role === 'admin' ? '管理员' : '普通用户'}` : a.patch.status === 'suspended' ? '已停用' : '已恢复';
+ return {...state, users: state.users.map(x => (x.id === a.user ? {...x, ...a.patch} : x)), adminLog: [...state.adminLog, {at: Date.now(), text: `${u.name}:${what}`}]};
+ }
+ case 'rates': return {...state, rates: {...state.rates, ...a.patch}};
+ case 'ratesPublish': return {...state, ratesRev: state.ratesRev + 1, adminLog: [...state.adminLog, {at: Date.now(), text: `发布定价 v${state.ratesRev + 1}:快速 ${state.rates.fast} · 标准 ${state.rates.draft} · 精修 ${state.rates.final} 积分/秒`}]};
+ case 'overhead': return {...state, overhead: a.value};
default: return state;
}
}
+// ---- credits: every newly queued take is charged when it is submitted (refunded if it fails) ----
+const queuedIds = st => new Set([...st.shots.flatMap(x => x.versions.filter(v => v.status === 'queued').map(v => v.id)),
+ ...st.sessions.flatMap(x => x.messages.filter(m => m.role === 'result' && m.status === 'queued').map(m => m.id))]);
+function newCost(prev, next) {
+ const before = queuedIds(prev);
+ let n = 0, cost = 0;
+ next.shots.forEach(x => x.versions.forEach(v => { if (v.status === 'queued' && !before.has(v.id)) { n++; cost += creditsFor({quality: v.quality, duration: x.duration || 5}, next.rates); } }));
+ next.sessions.forEach(x => x.messages.forEach(m => { if (m.role === 'result' && m.status === 'queued' && !before.has(m.id)) { n++; cost += creditsFor({quality: m.quality, duration: m.duration || 5}, next.rates); } }));
+ return {n, cost};
+}
+function reducer(state, a) {
+ if (!['generate', 'qcPush', 'agentDemo'].includes(a.type)) return core(state, a);
+ const next = core(state, a), {n, cost} = newCost(state, next);
+ if (!cost) return next;
+ if (cost > state.credits.balance) return {...state, creditBlock: {need: cost, have: state.credits.balance, at: Date.now()}};
+ const entry = {id: uid('ce'), at: Date.now(), kind: 'spend', amount: -cost, label: `生成 · ${n} 条`};
+ return {...next, creditsToday: (next.creditsToday || 0) + cost, credits: {...next.credits, balance: next.credits.balance - cost, entries: [...next.credits.entries, entry]}};
+}
+
const Ctx = createContext(null);
export function StoreProvider({children}) {
diff --git a/design/inkseq-v2-prototype/src/styles.css b/design/inkseq-v2-prototype/src/styles.css
index bdfdbfdb..ce3304ba 100644
--- a/design/inkseq-v2-prototype/src/styles.css
+++ b/design/inkseq-v2-prototype/src/styles.css
@@ -1325,3 +1325,135 @@ button.ghost.danger:hover { background: rgba(255,122,107,.1); }
.empty-state small { max-width: 42ch; }
.seg.trash-filter { display: inline-flex; width: auto; flex-wrap: nowrap; gap: 0; }
+
+/* ---------- launch batch A/B: sign-in, credits, admin, AI-first new story, library folders ---------- */
+.auth-page { min-height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 16px; gap: 14px;
+ background: radial-gradient(900px 500px at 50% -10%, rgba(205,242,110,.08), transparent 60%), var(--bg); }
+.auth-card { width: min(420px, 100%); background: var(--surface); border: 1px solid var(--line-2); border-radius: var(--r-lg); padding: 28px 26px; display: flex; flex-direction: column; gap: 12px; box-shadow: var(--shadow-lg); }
+.auth-card .logo { font-size: 18px; letter-spacing: .04em; display: inline-flex; align-items: center; gap: 8px; }
+.auth-card .logo::before { content: ''; width: 18px; height: 18px; border-radius: 5px; background: linear-gradient(135deg, var(--accent), #6fd39a); }
+.auth-card h1 { margin: 4px 0 0; font-size: 22px; }
+.auth-card p { margin: 0; line-height: 1.6; }
+.auth-providers { display: flex; flex-direction: column; gap: 8px; }
+.auth-provider { justify-content: center; height: 40px; background: var(--surface-2); font-weight: 500; }
+.auth-or { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; }
+.auth-or::before, .auth-or::after { content: ''; flex: 1; height: 1px; background: var(--line-2); }
+.auth-main { justify-content: center; height: 40px; }
+.code-input { letter-spacing: .5em; font-size: 18px; text-align: center; font-variant-numeric: tabular-nums; }
+.auth-agree { align-items: flex-start; line-height: 1.5; }
+.auth-agree a { color: var(--accent); }
+.auth-foot { font-size: 11px; }
+
+.credits-chip { background: var(--surface); border-color: var(--line); padding: 5px 10px; gap: 6px; height: 32px; }
+.credits-chip svg { color: var(--accent); }
+.credits-chip b { font-variant-numeric: tabular-nums; }
+.credits-chip .credits-word { color: var(--muted); font-size: 12px; }
+.credits-chip.low { border-color: rgba(243,182,90,.5); }
+.credits-chip.low svg { color: var(--warn); }
+.credits-hero { flex-direction: row; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
+.credits-hero > div { display: flex; flex-direction: column; gap: 4px; }
+.credits-big { display: flex; align-items: center; gap: 8px; font-size: 34px; font-weight: 700; font-variant-numeric: tabular-nums; }
+.credits-big svg { color: var(--accent); }
+.credits-big span { font-size: 14px; font-weight: 500; color: var(--muted); }
+.credits-today { min-width: 200px; }
+.credits-today .bar { display: block; height: 6px; border-radius: 6px; background: var(--surface-3); overflow: hidden; }
+.credits-today .bar b { display: block; height: 100%; background: var(--accent); }
+.packs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
+.pack { position: relative; flex-direction: column; align-items: flex-start; gap: 4px; padding: 14px; border-radius: var(--r); background: var(--surface-2); }
+.pack b { font-size: 20px; font-variant-numeric: tabular-nums; }
+.pack b small { font-size: 12px; color: var(--muted); font-weight: 500; }
+.pack .pack-name { color: var(--ink-2); font-size: 12px; }
+.pack .pack-price { margin-top: 6px; font-weight: 600; }
+.pack.best { border-color: var(--accent); }
+.pack-tag { position: absolute; top: -9px; right: 10px; font-style: normal; font-size: 11px; background: var(--accent); color: var(--accent-ink); border-radius: 999px; padding: 1px 8px; font-weight: 600; }
+.good { color: var(--good); }
+.bad { color: var(--bad); }
+.price-table { display: grid; gap: 8px; }
+.price-table > div { display: grid; grid-template-columns: 110px 1fr auto auto; gap: 12px; align-items: center; padding: 8px 10px; background: var(--surface-2); border-radius: var(--r-sm); }
+.price-table > div span { font-variant-numeric: tabular-nums; white-space: nowrap; }
+.limits-box .limit-row { padding: 6px 0; }
+.credit-log { display: flex; flex-direction: column; }
+.credit-log > div { display: grid; grid-template-columns: 18px 1fr auto auto; gap: 10px; align-items: center; padding: 8px 2px; border-bottom: 1px solid var(--line); }
+.credit-log > div:last-child { border-bottom: 0; }
+.credit-log b { font-variant-numeric: tabular-nums; min-width: 64px; text-align: right; }
+.checkout { width: min(460px, 94vw); }
+.checkout-sum { display: flex; justify-content: space-between; align-items: center; padding: 12px; background: var(--surface-2); border-radius: var(--r); font-size: 15px; }
+.stripe-mock { background: #f6f7fb; color: #1a1f36; border-radius: var(--r); padding: 16px; display: flex; flex-direction: column; gap: 8px; }
+.stripe-mock .muted { color: #697386; }
+.stripe-field { border: 1px solid #d5d9e2; border-radius: 6px; padding: 9px 10px; background: #fff; color: #3c4257; font-variant-numeric: tabular-nums; }
+.login-row { padding: 6px 0; border-bottom: 1px solid var(--line); }
+.login-row b { width: 70px; }
+.admin-tabs { align-self: flex-start; }
+.user-table { display: flex; flex-direction: column; font-size: 12px; }
+.user-row { display: grid; grid-template-columns: minmax(0, 1fr) 112px 64px 80px 108px; gap: 10px; align-items: center; padding: 9px 4px; border-bottom: 1px solid var(--line); }
+.user-row > span { display: flex; flex-direction: column; min-width: 0; }
+.user-row.head { color: var(--muted); font-size: 11px; }
+.user-row.off { opacity: .55; }
+.user-row .num { font-variant-numeric: tabular-nums; }
+.user-adjust { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; padding: 10px; background: var(--surface-2); border-radius: var(--r-sm); margin: 4px 0 8px; }
+.rate-edit { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
+.rate-edit .field { width: 120px; }
+.margin-table { display: flex; flex-direction: column; font-size: 12px; }
+.margin-table > div { display: grid; grid-template-columns: 2fr 1fr 1fr 1.4fr 70px; gap: 10px; align-items: center; padding: 8px 2px; border-bottom: 1px solid var(--line); font-variant-numeric: tabular-nums; }
+.margin-table > div > span:first-child { display: flex; flex-direction: column; }
+.margin-table .head { color: var(--muted); font-size: 11px; }
+.stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
+.stats-row > div { display: flex; flex-direction: column; gap: 2px; background: var(--surface-2); border-radius: var(--r-sm); padding: 10px 12px; }
+.stats-row b { font-size: 18px; font-variant-numeric: tabular-nums; }
+.role-preview { position: fixed; left: 10px; bottom: 30px; z-index: 31; display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted);
+ background: var(--surface); border: 1px solid var(--line-2); border-radius: 999px; padding: 3px 4px 3px 10px; box-shadow: var(--shadow); }
+.role-preview select { width: auto; padding: 2px 6px; font-size: 11px; border-radius: 999px; }
+
+.assist-hint { width: 100%; justify-content: flex-start; gap: 14px; padding: 14px 18px; margin: -8px 0 22px; border-radius: var(--r-lg); text-align: left;
+ background: linear-gradient(100deg, rgba(205,242,110,.10), rgba(116,173,255,.06)); border-color: rgba(205,242,110,.25); }
+.assist-hint > svg:first-child { color: var(--accent); flex: none; }
+.assist-hint > span { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; }
+.assist-hint b { font-size: 14px; }
+.assist-hint small { color: var(--ink-2); line-height: 1.5; }
+.modes.ai-first { grid-template-columns: 1.4fr 1fr; }
+.modes .ai-mode { position: relative; padding: 14px; gap: 5px; }
+.modes .ai-mode > svg { color: var(--accent); }
+.modes .ai-mode small { font-size: 12px; }
+.modes .rec { position: absolute; top: 10px; right: 10px; font-style: normal; font-size: 11px; background: var(--accent); color: var(--accent-ink); border-radius: 999px; padding: 1px 8px; font-weight: 600; }
+.modes-sub { display: flex; flex-direction: column; gap: 6px; }
+.modes-sub .modes button { padding: 9px 12px; }
+.import-steps { display: flex; flex-direction: column; gap: 10px; padding: 6px 2px; }
+.import-steps > div { display: flex; align-items: center; gap: 10px; color: var(--muted); }
+.import-steps > div.done { color: var(--ink-2); }
+.import-steps > div.done svg { color: var(--good); }
+.import-steps > div.now { color: var(--ink); font-weight: 600; }
+.import-steps i { width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--line-2); }
+
+.folder-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 16px; }
+.folder-card { flex-direction: column; align-items: stretch; gap: 6px; padding: 10px; border-radius: var(--r-lg); background: var(--surface); border-color: var(--line); text-align: left; min-width: 0; }
+.folder-card:hover { border-color: var(--line-2); }
+.folder-card.alert { border-color: rgba(205,242,110,.35); }
+.folder-cover { aspect-ratio: 16 / 10; border-radius: var(--r); background: var(--surface-2); overflow: hidden; display: grid; place-items: center; color: var(--muted); gap: 2px; }
+.folder-cover.n2 { grid-template-columns: 1fr 1fr; }
+.folder-cover.n3, .folder-cover.n4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
+.folder-cover.n3 img:first-child { grid-row: span 2; }
+.folder-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.lib-kinds { margin-left: 4px; }
+
+@media (max-width: 760px) {
+ .credits-chip .credits-word { display: none; }
+ .packs { grid-template-columns: repeat(2, 1fr); }
+ .price-table > div { grid-template-columns: 1fr auto; }
+ .price-table > div small { grid-column: 1 / -1; order: 3; }
+ .user-row { grid-template-columns: minmax(0, 1fr) auto; }
+ .user-row > span:nth-child(2), .user-row > span:nth-child(3), .user-row > span:nth-child(4), .user-row.head { display: none; }
+ .margin-table > div { grid-template-columns: 1.6fr 1fr 1fr 56px; }
+ .margin-table > div > span:nth-child(3) { display: none; }
+ .stats-row { grid-template-columns: 1fr; }
+ .modes.ai-first { grid-template-columns: 1fr; }
+ .folder-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
+ .assist-hint { margin: 0 0 16px; }
+ .role-preview { bottom: 70px; }
+}
+
+@media (min-width: 761px) and (max-width: 1180px) {
+ .hbar nav { position: static; transform: none; }
+ .credits-chip .credits-word { display: none; }
+}
+.pack b small { display: block; margin-top: 1px; }
+.admin-tabs button { gap: 5px; }