dsh-config-manager 0.1.38 → 0.1.39
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/lib/client.d.ts +0 -65
- package/lib/client.js +105 -918
- package/lib/client.js.map +1 -1
- package/lib/index.js +1 -1
- package/lib/market/github-repos.js +2 -2
- package/lib/market/github-repos.js.map +1 -1
- package/lib/ui/i18n.d.ts +0 -10
- package/lib/ui/i18n.js +0 -21
- package/lib/ui/i18n.js.map +1 -1
- package/package.json +1 -1
- package/src/client/market/MarketPanel.tsx +2 -14
- package/src/client/market/market-locales.ts +0 -117
- package/src/index.ts +1 -1
- package/src/market/github-repos.ts +2 -2
- package/src/ui/i18n.ts +0 -21
- package/lib/ui/market-publish.d.ts +0 -129
- package/lib/ui/market-publish.js +0 -168
- package/lib/ui/market-publish.js.map +0 -1
- package/src/client/market/PublishView.tsx +0 -388
- package/src/ui/market-publish.test.ts +0 -219
- package/src/ui/market-publish.ts +0 -256
package/lib/ui/market-publish.js
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 配置市场「发布到市场」向导的框架无关渲染/校验模型(m-market-publish,node 可测)。
|
|
3
|
-
*
|
|
4
|
-
* 设计纪律(docs/design/2026-08-19-market-publish-design.md §3.4):
|
|
5
|
-
* - 全部无副作用纯函数,React 壳(PublishView)只装配,不承载业务逻辑;
|
|
6
|
-
* - 5 步流程:选择配置包 → 本地校验 → 生成条目包 → 推送作者仓库 → 提交收录申请;
|
|
7
|
-
* - 状态组件内自持(同 MarketPanel 策略,不进 sessionStorage)。
|
|
8
|
-
*
|
|
9
|
-
* 安全硬约束(与安全不变量同款):
|
|
10
|
-
* - itemId 须过 SAFE_ITEM_ID_RE(防 items/<id>/ 路径越界,assertSafeItemId 同源正则);
|
|
11
|
-
* - repoUrl 可选但若填必须为 http(s) 且无 userinfo(轻量等价实现 sync validateRepoUrl 的
|
|
12
|
-
* 语义,不 import node 模块、浏览器/node 双端可跑;凭据永不拼入 URL);
|
|
13
|
-
* - 本层只生成 git 命令模板与 index.json 收录片段**文本**,不做任何 git 写操作、
|
|
14
|
-
* 不持有凭据(与 About 页「Star 由用户在 GitHub 完成」同款产品纪律);
|
|
15
|
-
* - 内容校验(zip 加固解包 / containsSecrets / SHA-256)在 Host 侧 prepare 端点,本层不管。
|
|
16
|
-
*/
|
|
17
|
-
import { zhUiT } from "./i18n.js";
|
|
18
|
-
import { SAFE_ITEM_ID_RE } from "../market/types.js";
|
|
19
|
-
/* ---------------------------------------------------------------- 步骤模型 */
|
|
20
|
-
/** 发布向导 5 步(设计文档 §3.4:选择配置包 / 本地校验 / 生成条目包 / 推送作者仓库 / 提交收录申请) */
|
|
21
|
-
export const PUBLISH_STEP_IDS = ['select', 'validate', 'prepare', 'push', 'submit'];
|
|
22
|
-
/** 5 步模型(顺序即流程;文案走 i18n 字典,不硬编码中文)。 */
|
|
23
|
-
export function publishSteps(t = zhUiT) {
|
|
24
|
-
return [
|
|
25
|
-
{ id: 'select', title: t('marketPublish.stepSelect') },
|
|
26
|
-
{ id: 'validate', title: t('marketPublish.stepValidate') },
|
|
27
|
-
{ id: 'prepare', title: t('marketPublish.stepPrepare') },
|
|
28
|
-
{ id: 'push', title: t('marketPublish.stepPush') },
|
|
29
|
-
{ id: 'submit', title: t('marketPublish.stepSubmit') },
|
|
30
|
-
];
|
|
31
|
-
}
|
|
32
|
-
/** 空表单初值(React 壳 useState 初始 + 测试用)。 */
|
|
33
|
-
export const EMPTY_PUBLISH_FORM = {
|
|
34
|
-
itemId: '',
|
|
35
|
-
name: '',
|
|
36
|
-
version: '',
|
|
37
|
-
description: '',
|
|
38
|
-
author: '',
|
|
39
|
-
categories: '',
|
|
40
|
-
repoUrl: '',
|
|
41
|
-
};
|
|
42
|
-
/**
|
|
43
|
-
* 发布仓库 URL 合法性校验(返回错误消息;null = 合法)。
|
|
44
|
-
* **可选字段**:空串/空白视为「未填」= 合法;非空须:
|
|
45
|
-
* - 不含空白字符(\s);
|
|
46
|
-
* - http(s) 开头(拒绝 git@ / ssh / ftp 等非 http(s) 形态,市场仓库只走 https clone);
|
|
47
|
-
* - authority 段不含 userinfo(`user[:password]@host`),凭据必须走 DSH credentials。
|
|
48
|
-
* 语义与 sync 的 validateRepoUrl 对齐(拒绝 userinfo / 空白),另按市场设计补强「非 http(s) 拒绝」。
|
|
49
|
-
*/
|
|
50
|
-
export function validatePublishRepoUrl(repoUrl, t = zhUiT) {
|
|
51
|
-
if (typeof repoUrl !== 'string' || repoUrl.trim() === '') {
|
|
52
|
-
return null; // 可选字段:未填 = 合法
|
|
53
|
-
}
|
|
54
|
-
const url = repoUrl.trim();
|
|
55
|
-
if (/\s/.test(url)) {
|
|
56
|
-
return t('marketPublish.repoWhitespace');
|
|
57
|
-
}
|
|
58
|
-
if (!/^https?:\/\//i.test(url)) {
|
|
59
|
-
return t('marketPublish.repoScheme');
|
|
60
|
-
}
|
|
61
|
-
const authority = url.replace(/^https?:\/\//i, '').split(/[/?#]/)[0] ?? '';
|
|
62
|
-
if (authority.includes('@')) {
|
|
63
|
-
return t('marketPublish.repoUserinfo');
|
|
64
|
-
}
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
/** 表单校验:itemId(SAFE_ITEM_ID_RE)+ name 必填 + repoUrl 可选校验。返回逐字段错误。 */
|
|
68
|
-
export function validatePublishForm(form, t = zhUiT) {
|
|
69
|
-
const errors = { itemId: null, name: null, repoUrl: null };
|
|
70
|
-
if (!SAFE_ITEM_ID_RE.test(form.itemId)) {
|
|
71
|
-
errors.itemId = t('marketPublish.errorItemId');
|
|
72
|
-
}
|
|
73
|
-
if (form.name.trim() === '') {
|
|
74
|
-
errors.name = t('marketPublish.errorName');
|
|
75
|
-
}
|
|
76
|
-
const repoErr = validatePublishRepoUrl(form.repoUrl, t);
|
|
77
|
-
if (repoErr !== null)
|
|
78
|
-
errors.repoUrl = repoErr;
|
|
79
|
-
return errors;
|
|
80
|
-
}
|
|
81
|
-
/** 表单是否全部合法(generate 按钮可用性 / canProceed 的 prepare 前置)。 */
|
|
82
|
-
export function publishFormValid(errors) {
|
|
83
|
-
return errors.itemId === null && errors.name === null && errors.repoUrl === null;
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* 生成「推送到作者仓库」的 git 命令模板(用户在**自己的公开仓库**里执行;
|
|
87
|
-
* 插件不做任何 git 写操作、不持有凭据)。
|
|
88
|
-
*
|
|
89
|
-
* 语义:clone 空仓库 → 进入 → 建 items/<id>/ → 从发布目录复制条目文件
|
|
90
|
-
* (prepare 产物解压为 `dist/`,含 dist/items/<id>/manifest.json + config.zip)→
|
|
91
|
-
* add → commit → push。
|
|
92
|
-
*/
|
|
93
|
-
export function buildGitPushCommands(input) {
|
|
94
|
-
const { repoUrl, itemId, dir } = input;
|
|
95
|
-
return [
|
|
96
|
-
`git clone ${repoUrl} ${dir}`,
|
|
97
|
-
`cd ${dir}`,
|
|
98
|
-
`mkdir -p items/${itemId}`,
|
|
99
|
-
`cp -r ../dist/items/${itemId}/. items/${itemId}/`,
|
|
100
|
-
`git add items/${itemId}`,
|
|
101
|
-
`git commit -m "publish ${itemId}"`,
|
|
102
|
-
`git push origin HEAD`,
|
|
103
|
-
];
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* 生成「提交收录申请」(PR)时追加进官方 index.json `items[]` 的条目片段
|
|
107
|
-
* (pretty JSON 文本,`JSON.parse` 可直接通过;可选字段空值不写入)。
|
|
108
|
-
* 字段形状与 src/market/index-parser.ts 的白名单解析面一致。
|
|
109
|
-
*/
|
|
110
|
-
export function buildIndexEntrySnippet(item) {
|
|
111
|
-
const entry = {
|
|
112
|
-
id: item.id,
|
|
113
|
-
name: item.name,
|
|
114
|
-
};
|
|
115
|
-
if (item.description !== undefined && item.description.trim() !== '') {
|
|
116
|
-
entry.description = item.description.trim();
|
|
117
|
-
}
|
|
118
|
-
if (item.author !== undefined && item.author.trim() !== '') {
|
|
119
|
-
entry.author = item.author.trim();
|
|
120
|
-
}
|
|
121
|
-
if (item.version !== undefined && item.version.trim() !== '') {
|
|
122
|
-
entry.version = item.version.trim();
|
|
123
|
-
}
|
|
124
|
-
if (item.updatedAt !== undefined && item.updatedAt.trim() !== '') {
|
|
125
|
-
entry.updatedAt = item.updatedAt.trim();
|
|
126
|
-
}
|
|
127
|
-
if (item.categories !== undefined && item.categories.length > 0) {
|
|
128
|
-
entry.categories = item.categories;
|
|
129
|
-
}
|
|
130
|
-
entry.repo = item.repo;
|
|
131
|
-
return JSON.stringify(entry, null, 2);
|
|
132
|
-
}
|
|
133
|
-
/** 初值(进入发布向导时的状态)。 */
|
|
134
|
-
export const INITIAL_PUBLISH_STATE = {
|
|
135
|
-
step: 'select',
|
|
136
|
-
zipSelected: false,
|
|
137
|
-
validated: false,
|
|
138
|
-
prepared: false,
|
|
139
|
-
pushAcknowledged: false,
|
|
140
|
-
};
|
|
141
|
-
/**
|
|
142
|
-
* 当前步骤是否可前进到下一步(纯函数推导,React 壳只装配):
|
|
143
|
-
* - `select` :须已选择配置 zip(zipSelected);
|
|
144
|
-
* - `validate`:须本地校验通过(validated,dry-run 合法 + 无 secrets);
|
|
145
|
-
* - `prepare` :须条目包已生成(prepared,含表单校验通过——生成动作本身以 publishFormValid 为前提);
|
|
146
|
-
* - `push` :引导页无硬性前置(复制命令即可前进到收录申请);
|
|
147
|
-
* - `submit` :最后一步,不可再前进。
|
|
148
|
-
*/
|
|
149
|
-
export function canProceed(state) {
|
|
150
|
-
switch (state.step) {
|
|
151
|
-
case 'select': return state.zipSelected;
|
|
152
|
-
case 'validate': return state.validated;
|
|
153
|
-
case 'prepare': return state.prepared;
|
|
154
|
-
case 'push': return true;
|
|
155
|
-
case 'submit': return false;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
/** 线性下一步(最后一步返回 null)。 */
|
|
159
|
-
export function nextStepId(step) {
|
|
160
|
-
const i = PUBLISH_STEP_IDS.indexOf(step);
|
|
161
|
-
return i >= 0 && i < PUBLISH_STEP_IDS.length - 1 ? PUBLISH_STEP_IDS[i + 1] ?? null : null;
|
|
162
|
-
}
|
|
163
|
-
/** 线性上一步(第一步返回 null)。 */
|
|
164
|
-
export function prevStepId(step) {
|
|
165
|
-
const i = PUBLISH_STEP_IDS.indexOf(step);
|
|
166
|
-
return i > 0 ? PUBLISH_STEP_IDS[i - 1] ?? null : null;
|
|
167
|
-
}
|
|
168
|
-
//# sourceMappingURL=market-publish.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"market-publish.js","sourceRoot":"","sources":["../../src/ui/market-publish.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,388 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 配置市场「发布到市场」发布向导(阶段 2,docs/design/2026-08-19-market-publish-design.md §3.4)。
|
|
3
|
-
*
|
|
4
|
-
* 5 步流程(全部渲染/校验模型来自 src/ui/market-publish.ts 纯函数,node 已测;本组件只装配):
|
|
5
|
-
* ①选择配置包 —— 复用导出 zip(本地文件选择 → upload 受控临时区,零新增导入逻辑);
|
|
6
|
-
* ②本地校验 —— analyzeImport dry-run(零写入):内容合法且不含密钥(secretCount===0);
|
|
7
|
-
* ③生成条目包 —— 表单(name/description/author/categories/id/repoUrl 可选)+ POST /market/prepare
|
|
8
|
-
* → 展示 manifest 文本 / SHA-256 / sections(可复制);
|
|
9
|
-
* ④推送作者仓库 —— t4 控制器生成的 git 命令模板(可复制);插件不做任何 git 写操作、不持有凭据;
|
|
10
|
-
* ⑤提交收录申请 —— index.json 追加片段(可复制)+ fork 官方仓库提 PR 指引。
|
|
11
|
-
*
|
|
12
|
-
* 状态策略:组件内自持(useState,不进 sessionStorage —— 发布为一次性低频率流程,同 MarketPanel 的低频面板策略)。
|
|
13
|
-
* 安全:市场通道永不携带秘密(步骤 2 拒绝含密钥 zip,prepare 亦双保险拒绝 containsSecrets);
|
|
14
|
-
* 展示文本渲染前过 redact() 兜底;repoUrl 已由 validatePublishRepoUrl 拒绝 userinfo,凭据绝不拼入命令/片段。
|
|
15
|
-
*/
|
|
16
|
-
import { useRef, useState } from 'react'
|
|
17
|
-
import type { ChangeEvent, ReactNode } from 'react'
|
|
18
|
-
import type { TranslateNS } from '../client-types.ts'
|
|
19
|
-
import type { ConfigManagerApi } from '../api.ts'
|
|
20
|
-
import type { MarketApi } from './market-api.ts'
|
|
21
|
-
import type { MarketPrepareResponse } from '../../market/types.ts'
|
|
22
|
-
import { Badge, Banner, Button, Card, Field, SectionTitle, Spinner } from '../common/ui.tsx'
|
|
23
|
-
import { redact } from '../../security/redaction.ts'
|
|
24
|
-
import {
|
|
25
|
-
buildGitPushCommands, buildIndexEntrySnippet, canProceed, EMPTY_PUBLISH_FORM,
|
|
26
|
-
PUBLISH_STEP_IDS, publishFormValid, publishSteps, validatePublishForm,
|
|
27
|
-
} from '../../ui/market-publish.ts'
|
|
28
|
-
import type { PublishFieldErrors, PublishFormFields } from '../../ui/market-publish.ts'
|
|
29
|
-
import css from '../config-manager.module.css'
|
|
30
|
-
|
|
31
|
-
export interface PublishViewProps {
|
|
32
|
-
/** 市场 API(prepare:生成条目包) */
|
|
33
|
-
api: MarketApi
|
|
34
|
-
/** 主 ConfigManagerApi(upload / analyzeImport:文件上传与本地 dry-run 校验) */
|
|
35
|
-
importApi: ConfigManagerApi
|
|
36
|
-
t: TranslateNS<'config-manager-market'>
|
|
37
|
-
/** 返回市场面板(关闭发布向导) */
|
|
38
|
-
onBack: () => void
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** 最近一次复制操作的标识(成功态提示用;'<key>-fail' 表示失败) */
|
|
42
|
-
type CopiedState = string | null
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* 发布向导(React 壳,只装配):
|
|
46
|
-
* - 步骤门控用 t4 控制器 canProceed(zipSelected / validated / prepared);
|
|
47
|
-
* - 表单校验用 validatePublishForm / publishFormValid(行内 formError);
|
|
48
|
-
* - git 命令 / index 片段文本来自 buildGitPushCommands / buildIndexEntrySnippet。
|
|
49
|
-
*/
|
|
50
|
-
export function PublishView({ api, importApi, t, onBack }: PublishViewProps) {
|
|
51
|
-
const uiT = api.t // 纯渲染模型翻译器(marketPublish.* 键,src/ui/i18n.ts)
|
|
52
|
-
const [stepIndex, setStepIndex] = useState(0)
|
|
53
|
-
// 步骤 1:配置包(上传结果)
|
|
54
|
-
const [fileName, setFileName] = useState<string | null>(null)
|
|
55
|
-
const [uploading, setUploading] = useState(false)
|
|
56
|
-
const [zipPath, setZipPath] = useState<string | null>(null)
|
|
57
|
-
// 步骤 2:本地校验
|
|
58
|
-
const [validating, setValidating] = useState(false)
|
|
59
|
-
const [validated, setValidated] = useState(false)
|
|
60
|
-
const [validationError, setValidationError] = useState<string | null>(null)
|
|
61
|
-
// 步骤 3:表单 + 生成
|
|
62
|
-
const [form, setForm] = useState<PublishFormFields>(EMPTY_PUBLISH_FORM)
|
|
63
|
-
const [formErrors, setFormErrors] = useState<PublishFieldErrors>({ itemId: null, name: null, repoUrl: null })
|
|
64
|
-
const [generating, setGenerating] = useState(false)
|
|
65
|
-
const [prepareResult, setPrepareResult] = useState<MarketPrepareResponse | null>(null)
|
|
66
|
-
const [prepareError, setPrepareError] = useState<string | null>(null)
|
|
67
|
-
// 全局错误(已 redact)
|
|
68
|
-
const [error, setError] = useState<string | null>(null)
|
|
69
|
-
const [copied, setCopied] = useState<CopiedState>(null)
|
|
70
|
-
const fileInput = useRef<HTMLInputElement>(null)
|
|
71
|
-
|
|
72
|
-
const steps = publishSteps(uiT)
|
|
73
|
-
const currentStep = PUBLISH_STEP_IDS[stepIndex]!
|
|
74
|
-
/** t4 状态机:zipSelected / validated / prepared 推导当前步可否前进 */
|
|
75
|
-
const canNext = canProceed({
|
|
76
|
-
step: currentStep,
|
|
77
|
-
zipSelected: zipPath !== null,
|
|
78
|
-
validated,
|
|
79
|
-
prepared: prepareResult !== null,
|
|
80
|
-
pushAcknowledged: false,
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
/** 解析逗号分隔类别 → 去空白数组(空结果省略)。 */
|
|
84
|
-
const parseCategories = (): string[] => form.categories.split(',').map((c) => c.trim()).filter((c) => c !== '')
|
|
85
|
-
|
|
86
|
-
/** 步骤 1:选择 zip → upload 受控临时区(换选以最新文件为准,恒清空 input value)。 */
|
|
87
|
-
const onPickFile = async (file: File | undefined): Promise<void> => {
|
|
88
|
-
if (file === undefined) return
|
|
89
|
-
setUploading(true)
|
|
90
|
-
setError(null)
|
|
91
|
-
setValidated(false)
|
|
92
|
-
setPrepareResult(null)
|
|
93
|
-
try {
|
|
94
|
-
const uploaded = await importApi.upload(file)
|
|
95
|
-
setZipPath(uploaded.zipPath)
|
|
96
|
-
setFileName(file.name)
|
|
97
|
-
} catch (err) {
|
|
98
|
-
setError(err instanceof Error ? err.message : String(err))
|
|
99
|
-
} finally {
|
|
100
|
-
setUploading(false)
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** 步骤 2:analyzeImport dry-run(零写入)—— 无密钥 + 内容合法才放行。 */
|
|
105
|
-
const runValidate = async (): Promise<void> => {
|
|
106
|
-
if (zipPath === null) return
|
|
107
|
-
setValidating(true)
|
|
108
|
-
setValidationError(null)
|
|
109
|
-
try {
|
|
110
|
-
const analysis = await importApi.analyzeImport(zipPath)
|
|
111
|
-
if (analysis.secretCount > 0) {
|
|
112
|
-
setValidated(false)
|
|
113
|
-
setValidationError(t('publish.validate.secrets'))
|
|
114
|
-
} else if (!analysis.valid) {
|
|
115
|
-
setValidated(false)
|
|
116
|
-
setValidationError(t('publish.validate.invalid'))
|
|
117
|
-
} else {
|
|
118
|
-
setValidated(true)
|
|
119
|
-
}
|
|
120
|
-
} catch (err) {
|
|
121
|
-
setValidated(false)
|
|
122
|
-
setValidationError(err instanceof Error ? err.message : String(err))
|
|
123
|
-
} finally {
|
|
124
|
-
setValidating(false)
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** 步骤 3:表单字段更新 + 实时校验(t4 控制器)。 */
|
|
129
|
-
const onFormField = (field: keyof PublishFormFields, value: string): void => {
|
|
130
|
-
const next = { ...form, [field]: value }
|
|
131
|
-
setForm(next)
|
|
132
|
-
setFormErrors(validatePublishForm(next))
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** 步骤 3:POST /market/prepare 生成条目包(manifest + SHA-256 + sections)。 */
|
|
136
|
-
const runGenerate = async (): Promise<void> => {
|
|
137
|
-
if (zipPath === null) return
|
|
138
|
-
const errs = validatePublishForm(form)
|
|
139
|
-
setFormErrors(errs)
|
|
140
|
-
if (!publishFormValid(errs)) return
|
|
141
|
-
const categories = parseCategories()
|
|
142
|
-
setGenerating(true)
|
|
143
|
-
setPrepareError(null)
|
|
144
|
-
try {
|
|
145
|
-
const result = await api.prepare({
|
|
146
|
-
zipPath,
|
|
147
|
-
itemId: form.itemId,
|
|
148
|
-
name: form.name,
|
|
149
|
-
...(form.version !== '' ? { version: form.version } : {}),
|
|
150
|
-
...(form.description !== '' ? { description: form.description } : {}),
|
|
151
|
-
...(form.author !== '' ? { author: form.author } : {}),
|
|
152
|
-
...(form.repoUrl !== '' ? { repoUrl: form.repoUrl } : {}),
|
|
153
|
-
...(categories.length > 0 ? { categories } : {}),
|
|
154
|
-
})
|
|
155
|
-
setPrepareResult(result)
|
|
156
|
-
} catch (err) {
|
|
157
|
-
setPrepareError(err instanceof Error ? err.message : String(err))
|
|
158
|
-
} finally {
|
|
159
|
-
setGenerating(false)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** 复制文本到剪贴板(失败降级提示手动选择)。 */
|
|
164
|
-
const copyText = async (label: string, text: string): Promise<void> => {
|
|
165
|
-
try {
|
|
166
|
-
await navigator.clipboard.writeText(text)
|
|
167
|
-
setCopied(label)
|
|
168
|
-
} catch {
|
|
169
|
-
setCopied(`${label}-fail`)
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** 复制反馈徽章:成功 → ok「已复制」;失败 → warn「复制失败」。 */
|
|
174
|
-
const copyBadge = (label: string) => (
|
|
175
|
-
copied === label
|
|
176
|
-
? <Badge kind="ok">{t('publish.copied')}</Badge>
|
|
177
|
-
: copied === `${label}-fail`
|
|
178
|
-
? <Badge kind="warn">{t('publish.copyFailed')}</Badge>
|
|
179
|
-
: null
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
/* ------------------------------------------------ 各步骤渲染(只装配) */
|
|
183
|
-
|
|
184
|
-
const renderSelect = () => (
|
|
185
|
-
<Card>
|
|
186
|
-
<span className={css.hint}>{t('publish.select.hint')}</span>
|
|
187
|
-
<input
|
|
188
|
-
ref={fileInput}
|
|
189
|
-
type="file"
|
|
190
|
-
accept=".zip,application/zip"
|
|
191
|
-
className={css.hiddenFile}
|
|
192
|
-
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
193
|
-
const picked = e.target.files?.[0]
|
|
194
|
-
e.target.value = '' // 恒清空 → 同一文件可再次选择(同 import 向导)
|
|
195
|
-
void onPickFile(picked)
|
|
196
|
-
}}
|
|
197
|
-
/>
|
|
198
|
-
{fileName !== null && (
|
|
199
|
-
<div className={css.statRow}>
|
|
200
|
-
<Badge kind="info">{t('publish.select.file', { name: fileName })}</Badge>
|
|
201
|
-
</div>
|
|
202
|
-
)}
|
|
203
|
-
<div className={css.actionRow}>
|
|
204
|
-
<Button variant="primary" disabled={uploading} onClick={() => { fileInput.current?.click() }}>
|
|
205
|
-
{uploading ? <Spinner label={t('publish.select.uploading')} /> : t(fileName !== null ? 'publish.select.reselect' : 'publish.select.browse')}
|
|
206
|
-
</Button>
|
|
207
|
-
</div>
|
|
208
|
-
</Card>
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
const renderValidate = () => (
|
|
212
|
-
<Card>
|
|
213
|
-
<span className={css.hint}>{t('publish.validate.hint')}</span>
|
|
214
|
-
<div className={css.actionRow}>
|
|
215
|
-
<Button variant="primary" disabled={zipPath === null || validating} onClick={() => { void runValidate() }}>
|
|
216
|
-
{validating ? <Spinner label={t('publish.validate.running')} /> : (validated ? t('publish.validate.retry') : t('publish.validate.run'))}
|
|
217
|
-
</Button>
|
|
218
|
-
</div>
|
|
219
|
-
{validated && <Badge kind="ok">{t('publish.validate.ok')}</Badge>}
|
|
220
|
-
{validationError !== null && <Banner kind="error">{redact(validationError)}</Banner>}
|
|
221
|
-
</Card>
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
const renderForm = () => (
|
|
225
|
-
<Card>
|
|
226
|
-
<span className={css.groupLabel}>{t('publish.form.title')}</span>
|
|
227
|
-
<Field label={t('publish.form.id')} hint={t('publish.form.idHint')}>
|
|
228
|
-
<input className={css.input} value={form.itemId} onChange={(e) => { onFormField('itemId', e.target.value) }} />
|
|
229
|
-
{formErrors.itemId !== null && <span className={css.formError}>{redact(formErrors.itemId)}</span>}
|
|
230
|
-
</Field>
|
|
231
|
-
<Field label={t('publish.form.name')}>
|
|
232
|
-
<input className={css.input} value={form.name} onChange={(e) => { onFormField('name', e.target.value) }} />
|
|
233
|
-
{formErrors.name !== null && <span className={css.formError}>{redact(formErrors.name)}</span>}
|
|
234
|
-
</Field>
|
|
235
|
-
<Field label={t('publish.form.description')}>
|
|
236
|
-
<textarea className={css.input} value={form.description} onChange={(e) => { onFormField('description', e.target.value) }} />
|
|
237
|
-
</Field>
|
|
238
|
-
<Field label={t('publish.form.author')}>
|
|
239
|
-
<input className={css.input} value={form.author} onChange={(e) => { onFormField('author', e.target.value) }} />
|
|
240
|
-
</Field>
|
|
241
|
-
<Field label={t('publish.form.version')}>
|
|
242
|
-
<input className={css.input} value={form.version} onChange={(e) => { onFormField('version', e.target.value) }} />
|
|
243
|
-
</Field>
|
|
244
|
-
<Field label={t('publish.form.categories')}>
|
|
245
|
-
<input className={css.input} value={form.categories} onChange={(e) => { onFormField('categories', e.target.value) }} />
|
|
246
|
-
</Field>
|
|
247
|
-
<Field label={t('publish.form.repoUrl')} hint={t('publish.form.repoUrlHint')}>
|
|
248
|
-
<input className={css.input} value={form.repoUrl} onChange={(e) => { onFormField('repoUrl', e.target.value) }} />
|
|
249
|
-
{formErrors.repoUrl !== null && <span className={css.formError}>{redact(formErrors.repoUrl)}</span>}
|
|
250
|
-
</Field>
|
|
251
|
-
<div className={css.actionRow}>
|
|
252
|
-
<Button variant="primary" disabled={generating || !publishFormValid(formErrors)} onClick={() => { void runGenerate() }}>
|
|
253
|
-
{generating ? <Spinner label={t('publish.form.generating')} /> : t('publish.form.generate')}
|
|
254
|
-
</Button>
|
|
255
|
-
</div>
|
|
256
|
-
{prepareError !== null && <Banner kind="error">{redact(prepareError)}</Banner>}
|
|
257
|
-
{prepareResult !== null && (
|
|
258
|
-
<div>
|
|
259
|
-
<span className={css.groupLabel}>{t('publish.prepare.title')}</span>
|
|
260
|
-
<Banner kind="warn">{t('publish.prepare.warnings')}</Banner>
|
|
261
|
-
<div className={css.statRow}>
|
|
262
|
-
<Badge kind="info">{t('publish.prepare.sha256', { hash: prepareResult.sha256 })}</Badge>
|
|
263
|
-
<Badge kind="info">{t('publish.prepare.sections', { sections: prepareResult.sections.join(', ') })}</Badge>
|
|
264
|
-
</div>
|
|
265
|
-
<span className={css.fieldLabel}>{t('publish.prepare.manifest')}</span>
|
|
266
|
-
<div className={css.actionRow}>
|
|
267
|
-
<Button onClick={() => { void copyText('manifest', prepareResult.manifestText) }}>
|
|
268
|
-
{t('publish.prepare.copyManifest')}
|
|
269
|
-
</Button>
|
|
270
|
-
{copyBadge('manifest')}
|
|
271
|
-
</div>
|
|
272
|
-
<pre className={css.reportText}>{redact(prepareResult.manifestText)}</pre>
|
|
273
|
-
<span className={css.hint}>{t('publish.prepare.dirHint', { dir: prepareResult.dir })}</span>
|
|
274
|
-
</div>
|
|
275
|
-
)}
|
|
276
|
-
</Card>
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
const renderPush = () => {
|
|
280
|
-
const repoUrl = form.repoUrl.trim()
|
|
281
|
-
const zipPath = prepareResult?.zipPath
|
|
282
|
-
const downloadBtn = zipPath !== undefined && (
|
|
283
|
-
<Button onClick={() => { window.open(api.downloadPublishUrl(zipPath), '_blank') }}>
|
|
284
|
-
{t('publish.push.download')}
|
|
285
|
-
</Button>
|
|
286
|
-
)
|
|
287
|
-
if (repoUrl === '') {
|
|
288
|
-
return (
|
|
289
|
-
<Card>
|
|
290
|
-
<Banner kind="warn">{t('publish.push.repoMissing', { id: form.itemId })}</Banner>
|
|
291
|
-
{downloadBtn !== false && <div className={css.actionRow}>{downloadBtn}</div>}
|
|
292
|
-
</Card>
|
|
293
|
-
)
|
|
294
|
-
}
|
|
295
|
-
const commands = buildGitPushCommands({ repoUrl, itemId: form.itemId, dir: 'market-items' })
|
|
296
|
-
return (
|
|
297
|
-
<Card>
|
|
298
|
-
<span className={css.hint}>{t('publish.push.hint')}</span>
|
|
299
|
-
<div className={css.actionRow}>
|
|
300
|
-
{downloadBtn !== false && downloadBtn}
|
|
301
|
-
<Button onClick={() => { void copyText('commands', commands.join('\n')) }}>{t('publish.push.copy')}</Button>
|
|
302
|
-
{copyBadge('commands')}
|
|
303
|
-
</div>
|
|
304
|
-
<pre className={css.reportText}>{redact(commands.join('\n'))}</pre>
|
|
305
|
-
</Card>
|
|
306
|
-
)
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const renderSubmit = () => {
|
|
310
|
-
const repoUrl = form.repoUrl.trim()
|
|
311
|
-
if (repoUrl === '') {
|
|
312
|
-
return (
|
|
313
|
-
<Card>
|
|
314
|
-
<Banner kind="warn">{t('publish.submit.repoMissing')}</Banner>
|
|
315
|
-
</Card>
|
|
316
|
-
)
|
|
317
|
-
}
|
|
318
|
-
const categories = parseCategories()
|
|
319
|
-
const snippet = buildIndexEntrySnippet({
|
|
320
|
-
id: form.itemId,
|
|
321
|
-
name: form.name,
|
|
322
|
-
...(form.description !== '' ? { description: form.description } : {}),
|
|
323
|
-
...(form.author !== '' ? { author: form.author } : {}),
|
|
324
|
-
...(form.version !== '' ? { version: form.version } : {}),
|
|
325
|
-
updatedAt: new Date().toISOString(),
|
|
326
|
-
...(categories.length > 0 ? { categories } : {}),
|
|
327
|
-
repo: repoUrl,
|
|
328
|
-
})
|
|
329
|
-
return (
|
|
330
|
-
<Card>
|
|
331
|
-
<span className={css.hint}>{t('publish.submit.hint')}</span>
|
|
332
|
-
<div className={css.actionRow}>
|
|
333
|
-
<Button onClick={() => { void copyText('snippet', snippet) }}>{t('publish.submit.copy')}</Button>
|
|
334
|
-
{copyBadge('snippet')}
|
|
335
|
-
</div>
|
|
336
|
-
<span className={css.fieldLabel}>{t('publish.submit.snippet')}</span>
|
|
337
|
-
<pre className={css.reportText}>{redact(snippet)}</pre>
|
|
338
|
-
<span className={css.hint}>{t('publish.submit.fork', { url: 'https://github.com/xiajiajun516/dsh-config-market' })}</span>
|
|
339
|
-
<span className={css.hint}>{t('publish.submit.manual')}</span>
|
|
340
|
-
</Card>
|
|
341
|
-
)
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/* ------------------------------------------------ 装配 */
|
|
345
|
-
|
|
346
|
-
const renderStep = (): ReactNode => {
|
|
347
|
-
switch (currentStep) {
|
|
348
|
-
case 'select': return renderSelect()
|
|
349
|
-
case 'validate': return renderValidate()
|
|
350
|
-
case 'prepare': return renderForm()
|
|
351
|
-
case 'push': return renderPush()
|
|
352
|
-
case 'submit': return renderSubmit()
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
return (
|
|
357
|
-
<div className={css.viewBody}>
|
|
358
|
-
<SectionTitle title={t('publish.title')} subtitle={t('publish.subtitle')} />
|
|
359
|
-
<div className={css.actionRow}>
|
|
360
|
-
<Button onClick={onBack}>{t('publish.back')}</Button>
|
|
361
|
-
</div>
|
|
362
|
-
{/* 步骤指示:当前步 + 总进度(t4 控制器模型) */}
|
|
363
|
-
<div className={css.statRow}>
|
|
364
|
-
{steps.map((s, i) => (
|
|
365
|
-
<Badge key={s.id} kind={i === stepIndex ? 'ok' : 'info'}>{`${i + 1}. ${s.title}`}</Badge>
|
|
366
|
-
))}
|
|
367
|
-
</div>
|
|
368
|
-
<div className={css.statRow}>
|
|
369
|
-
<Badge kind="info">
|
|
370
|
-
{t('publish.stepIndicator', { current: String(stepIndex + 1), total: String(PUBLISH_STEP_IDS.length), label: steps[stepIndex]?.title ?? '' })}
|
|
371
|
-
</Badge>
|
|
372
|
-
</div>
|
|
373
|
-
{error !== null && <Banner kind="error">{redact(error)}</Banner>}
|
|
374
|
-
{renderStep()}
|
|
375
|
-
{/* 底部导航:上一步 / 下一步(门控 = canProceed 推导) */}
|
|
376
|
-
<div className={css.actionRow}>
|
|
377
|
-
{stepIndex > 0 && (
|
|
378
|
-
<Button onClick={() => { setStepIndex((i) => i - 1) }}>{t('publish.prev')}</Button>
|
|
379
|
-
)}
|
|
380
|
-
{stepIndex < PUBLISH_STEP_IDS.length - 1 && (
|
|
381
|
-
<Button variant="primary" disabled={!canNext} onClick={() => { setStepIndex((i) => i + 1) }}>
|
|
382
|
-
{t('publish.next')}
|
|
383
|
-
</Button>
|
|
384
|
-
)}
|
|
385
|
-
</div>
|
|
386
|
-
</div>
|
|
387
|
-
)
|
|
388
|
-
}
|