Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7839380613 | |||
| 8aca116949 | |||
| 3a80ef9859 | |||
| 1a7b025aee | |||
| b3b916c6bc | |||
| e1cf2970da | |||
| 18b9d8eee5 | |||
| 48362bd85f | |||
| 6e06a67bde | |||
| d7b35d6149 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,12 +2,10 @@
|
|||||||
.old-reference/
|
.old-reference/
|
||||||
.local/
|
.local/
|
||||||
dist/
|
dist/
|
||||||
# dist-release/
|
|
||||||
# release/
|
# release/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
dist-release/
|
|
||||||
dist-release.pem
|
dist-release.pem
|
||||||
dist-release.crx
|
dist-release.crx
|
||||||
|
|
||||||
|
|||||||
@ -21,9 +21,7 @@
|
|||||||
- `src/`
|
- `src/`
|
||||||
- 插件源码
|
- 插件源码
|
||||||
- `dist/`
|
- `dist/`
|
||||||
- 开发构建产物
|
- 开发和发布构建产物
|
||||||
- `dist-release/`
|
|
||||||
- 内部分发构建产物
|
|
||||||
- `release/`
|
- `release/`
|
||||||
- 打包后的内部交付压缩包
|
- 打包后的内部交付压缩包
|
||||||
- `docs/`
|
- `docs/`
|
||||||
@ -84,7 +82,7 @@ npm run write:latest
|
|||||||
|
|
||||||
生成结果:
|
生成结果:
|
||||||
|
|
||||||
- 构建目录:`dist-release/`
|
- 构建目录:`dist/`
|
||||||
- 压缩包:`release/star-chart-search-enhancer-internal.zip`
|
- 压缩包:`release/star-chart-search-enhancer-internal.zip`
|
||||||
- 更新清单:`release/latest.json`
|
- 更新清单:`release/latest.json`
|
||||||
|
|
||||||
@ -106,7 +104,7 @@ npm run write:latest
|
|||||||
2. 打开 `chrome://extensions`
|
2. 打开 `chrome://extensions`
|
||||||
3. 打开右上角 `开发者模式`
|
3. 打开右上角 `开发者模式`
|
||||||
4. 点击 `加载已解压的扩展程序`
|
4. 点击 `加载已解压的扩展程序`
|
||||||
5. 选择解压后的插件文件夹
|
5. 选择解压后的 `dist/` 文件夹
|
||||||
|
|
||||||
安装后请确认扩展 ID 是:
|
安装后请确认扩展 ID 是:
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 777 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB |
@ -1,13 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="16" y1="12" x2="114" y2="116" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#9F1239"/>
|
|
||||||
<stop offset="1" stop-color="#4C0519"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#bg)"/>
|
|
||||||
<path d="M34 80L50 62L64 70L83 46" stroke="#FFF7ED" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
|
|
||||||
<circle cx="86" cy="44" r="8" fill="#FFF7ED"/>
|
|
||||||
<path d="M79 80C79 71.7157 85.7157 65 94 65C102.284 65 109 71.7157 109 80C109 88.2843 102.284 95 94 95C85.7157 95 79 88.2843 79 80Z" stroke="#FFF7ED" stroke-width="8"/>
|
|
||||||
<path d="M104 91L114 101" stroke="#FFF7ED" stroke-linecap="round" stroke-width="8"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 822 B |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,585 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
(() => {
|
|
||||||
// src/shared/rate-normalizer.ts
|
|
||||||
function normalizeFractionRateDisplay(value) {
|
|
||||||
const numericValue = Number(value);
|
|
||||||
if (!Number.isFinite(numericValue)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const percentageValue = numericValue * 100;
|
|
||||||
return `${trimTrailingZeros(percentageValue.toFixed(6))}%`;
|
|
||||||
}
|
|
||||||
function trimTrailingZeros(value) {
|
|
||||||
return value.replace(/\.?0+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/content/market/market-list-request-snapshot.ts
|
|
||||||
var MARKET_REQUEST_SNAPSHOT_ATTRIBUTE = "data-sces-market-request-snapshot";
|
|
||||||
function writeMarketListRequestSnapshot(document2, snapshot) {
|
|
||||||
document2.documentElement.setAttribute(
|
|
||||||
MARKET_REQUEST_SNAPSHOT_ATTRIBUTE,
|
|
||||||
JSON.stringify(snapshot)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/content/market/market-list-row.ts
|
|
||||||
var PAGE_NUMBER_KEYS = [
|
|
||||||
"currentPage",
|
|
||||||
"page",
|
|
||||||
"pageNo",
|
|
||||||
"pageNum",
|
|
||||||
"page_no",
|
|
||||||
"page_num"
|
|
||||||
];
|
|
||||||
var PAGE_SIZE_KEYS = [
|
|
||||||
"limit",
|
|
||||||
"pageSize",
|
|
||||||
"page_size",
|
|
||||||
"size"
|
|
||||||
];
|
|
||||||
var TOTAL_COUNT_KEYS = [
|
|
||||||
"total",
|
|
||||||
"totalCount",
|
|
||||||
"total_count"
|
|
||||||
];
|
|
||||||
var TOTAL_PAGE_KEYS = [
|
|
||||||
"pageCount",
|
|
||||||
"page_count",
|
|
||||||
"totalPage",
|
|
||||||
"totalPages",
|
|
||||||
"total_page",
|
|
||||||
"total_pages"
|
|
||||||
];
|
|
||||||
function mapMarketListRow(row) {
|
|
||||||
const attributeDatas = readMarketAttributeDatas(row);
|
|
||||||
const singleVideoAfterSearchRate = normalizeMarketListRate(
|
|
||||||
readMarketFieldValue(row, attributeDatas, "avg_search_after_view_rate_30d")
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
authorId: readString(readMarketFieldValue(row, attributeDatas, "star_id")) ?? readString(readMarketFieldValue(row, attributeDatas, "id")) ?? "",
|
|
||||||
authorName: readString(readMarketFieldValue(row, attributeDatas, "nickname")) ?? readString(readMarketFieldValue(row, attributeDatas, "nick_name")) ?? "",
|
|
||||||
coreUserId: readString(readMarketFieldValue(row, attributeDatas, "core_user_id")) ?? void 0,
|
|
||||||
exportFields: buildMarketExportFieldFallbacks(row, attributeDatas),
|
|
||||||
hasDirectRatesSource: true,
|
|
||||||
location: readMarketLocation(row, attributeDatas),
|
|
||||||
price21To60s: readMarketPrice21To60s(row, attributeDatas),
|
|
||||||
rates: singleVideoAfterSearchRate ? {
|
|
||||||
singleVideoAfterSearchRate
|
|
||||||
} : void 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function parseMarketListResponse(payload) {
|
|
||||||
const container = findMarketListContainer(payload);
|
|
||||||
if (!container) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const marketList = readMarketListArray(container);
|
|
||||||
if (!marketList) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
currentPage: readKnownNumberDeep(container, PAGE_NUMBER_KEYS) ?? void 0,
|
|
||||||
pageSize: readKnownNumberDeep(container, PAGE_SIZE_KEYS) ?? void 0,
|
|
||||||
records: marketList.map((row) => isRecord(row) ? mapMarketListRow(row) : null).filter(
|
|
||||||
(row) => row !== null && Boolean(row.authorId || row.authorName)
|
|
||||||
),
|
|
||||||
totalCount: readKnownNumberDeep(container, TOTAL_COUNT_KEYS) ?? void 0,
|
|
||||||
totalPages: readKnownNumberDeep(container, TOTAL_PAGE_KEYS) ?? void 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function findMarketListContainer(value) {
|
|
||||||
const queue = [value];
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const current = queue.shift();
|
|
||||||
if (!isRecord(current)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (readMarketListArray(current)) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
Object.values(current).forEach((entry) => {
|
|
||||||
queue.push(unwrapVueRef(entry));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function readMarketListArray(record) {
|
|
||||||
const marketList = unwrapVueRef(record.marketList);
|
|
||||||
if (Array.isArray(marketList)) {
|
|
||||||
return marketList;
|
|
||||||
}
|
|
||||||
const authors = unwrapVueRef(record.authors);
|
|
||||||
if (Array.isArray(authors)) {
|
|
||||||
return authors;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function unwrapVueRef(value) {
|
|
||||||
if (isRecord(value) && "value" in value) {
|
|
||||||
return value.value;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
function isRecord(value) {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
function readMarketAttributeDatas(record) {
|
|
||||||
return isRecord(record.attribute_datas) ? record.attribute_datas : {};
|
|
||||||
}
|
|
||||||
function readMarketFieldValue(record, attributeDatas, field) {
|
|
||||||
return record[field] ?? attributeDatas[field];
|
|
||||||
}
|
|
||||||
function readString(value) {
|
|
||||||
return typeof value === "string" ? value : null;
|
|
||||||
}
|
|
||||||
function normalizeMarketListRate(value) {
|
|
||||||
if (typeof value === "number") {
|
|
||||||
return normalizeFractionRateDisplay(String(value));
|
|
||||||
}
|
|
||||||
return typeof value === "string" ? normalizeFractionRateDisplay(value) : null;
|
|
||||||
}
|
|
||||||
function normalizeExportCellText(value) {
|
|
||||||
return value?.replace(/\s+/g, " ").trim() ?? "";
|
|
||||||
}
|
|
||||||
function buildMarketExportFieldFallbacks(record, attributeDatas) {
|
|
||||||
const exportFields = {};
|
|
||||||
const authorInfo = buildMarketAuthorInfo(record, attributeDatas);
|
|
||||||
const authorType = buildMarketAuthorType(record, attributeDatas);
|
|
||||||
const contentTheme = buildMarketContentTheme(record, attributeDatas);
|
|
||||||
const connectedUsers = formatWanValue(
|
|
||||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "link_link_cnt_by_industry"))
|
|
||||||
);
|
|
||||||
const followerCount = formatWanValue(
|
|
||||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "follower"))
|
|
||||||
);
|
|
||||||
const expectedCpm = formatDecimalDisplay(
|
|
||||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "prospective_20_60_cpm"))
|
|
||||||
);
|
|
||||||
const expectedPlayCount = formatWanValue(
|
|
||||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "expected_play_num"))
|
|
||||||
);
|
|
||||||
const interactionRate = formatFractionPercent(
|
|
||||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "interact_rate_within_30d"))
|
|
||||||
);
|
|
||||||
const finishRate = formatFractionPercent(
|
|
||||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "play_over_rate_within_30d"))
|
|
||||||
);
|
|
||||||
const burstRate = readBurstRateDisplay(
|
|
||||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "burst_text_rate"))
|
|
||||||
);
|
|
||||||
const price21To60s = readMarketPrice21To60s(record, attributeDatas);
|
|
||||||
const representativeVideo = readMarketRepresentativeVideo(record, attributeDatas);
|
|
||||||
assignExportField(exportFields, "\u8FBE\u4EBA\u4FE1\u606F", authorInfo);
|
|
||||||
assignExportField(exportFields, "\u4EE3\u8868\u89C6\u9891", representativeVideo);
|
|
||||||
assignExportField(exportFields, "\u8FBE\u4EBA\u7C7B\u578B", authorType);
|
|
||||||
assignExportField(exportFields, "\u5185\u5BB9\u4E3B\u9898", contentTheme);
|
|
||||||
assignExportField(exportFields, "\u8FDE\u63A5\u7528\u6237\u6570", connectedUsers);
|
|
||||||
assignExportField(exportFields, "\u7C89\u4E1D\u6570", followerCount);
|
|
||||||
assignExportField(exportFields, "\u9884\u671FCPM", expectedCpm);
|
|
||||||
assignExportField(exportFields, "\u9884\u671F\u64AD\u653E\u91CF", expectedPlayCount);
|
|
||||||
assignExportField(exportFields, "\u4E92\u52A8\u7387", interactionRate);
|
|
||||||
assignExportField(exportFields, "\u5B8C\u64AD\u7387", finishRate);
|
|
||||||
assignExportField(exportFields, "\u7206\u6587\u7387", burstRate);
|
|
||||||
assignExportField(exportFields, "21-60s\u62A5\u4EF7", price21To60s);
|
|
||||||
return Object.keys(exportFields).length > 0 ? exportFields : void 0;
|
|
||||||
}
|
|
||||||
function assignExportField(exportFields, key, value) {
|
|
||||||
if (hasTextValue(value)) {
|
|
||||||
exportFields[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function hasTextValue(value) {
|
|
||||||
return Boolean(value && value.trim().length > 0);
|
|
||||||
}
|
|
||||||
function buildMarketAuthorInfo(record, attributeDatas) {
|
|
||||||
const nickname = readString(readMarketFieldValue(record, attributeDatas, "nickname")) ?? readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ?? "";
|
|
||||||
const parts = [
|
|
||||||
nickname,
|
|
||||||
readMarketGenderLabel(readMarketFieldValue(record, attributeDatas, "gender")),
|
|
||||||
readString(readMarketFieldValue(record, attributeDatas, "city")) ?? ""
|
|
||||||
].filter((value) => Boolean(value));
|
|
||||||
return parts.length > 0 ? parts.join(" ") : void 0;
|
|
||||||
}
|
|
||||||
function buildMarketAuthorType(record, attributeDatas) {
|
|
||||||
const tagsRelation = readRecordLike(
|
|
||||||
readMarketFieldValue(record, attributeDatas, "tags_relation")
|
|
||||||
);
|
|
||||||
if (tagsRelation) {
|
|
||||||
const primaryTag = Object.keys(tagsRelation)[0];
|
|
||||||
if (hasTextValue(primaryTag)) {
|
|
||||||
return primaryTag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return void 0;
|
|
||||||
}
|
|
||||||
function buildMarketContentTheme(record, attributeDatas) {
|
|
||||||
const themes = readStringArray(
|
|
||||||
readMarketFieldValue(record, attributeDatas, "content_theme_labels_180d")
|
|
||||||
);
|
|
||||||
if (themes.length === 0) {
|
|
||||||
return void 0;
|
|
||||||
}
|
|
||||||
if (themes.length <= 2) {
|
|
||||||
return themes.join(" ");
|
|
||||||
}
|
|
||||||
return `${themes.slice(0, 2).join(" ")} ${themes.length - 2}+`;
|
|
||||||
}
|
|
||||||
function readMarketLocation(record, attributeDatas) {
|
|
||||||
return readString(readMarketFieldValue(record, attributeDatas, "city")) ?? void 0;
|
|
||||||
}
|
|
||||||
function readMarketPrice21To60s(record, attributeDatas) {
|
|
||||||
return formatCurrencyValue(
|
|
||||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "price_20_60"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function readMarketRepresentativeVideo(record, attributeDatas) {
|
|
||||||
const items = readArrayLike(readMarketFieldValue(record, attributeDatas, "items"));
|
|
||||||
for (const item of items) {
|
|
||||||
if (!isRecord(item)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const title = readString(item.title);
|
|
||||||
if (hasTextValue(title)) {
|
|
||||||
return normalizeExportCellText(title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return void 0;
|
|
||||||
}
|
|
||||||
function readMarketGenderLabel(value) {
|
|
||||||
const rawValue = typeof value === "number" ? String(value) : readString(value);
|
|
||||||
if (rawValue === "1") {
|
|
||||||
return "\u7537";
|
|
||||||
}
|
|
||||||
if (rawValue === "2") {
|
|
||||||
return "\u5973";
|
|
||||||
}
|
|
||||||
return void 0;
|
|
||||||
}
|
|
||||||
function readBurstRateDisplay(value) {
|
|
||||||
if (value === null) {
|
|
||||||
return void 0;
|
|
||||||
}
|
|
||||||
if (value <= 0) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
return formatFractionPercent(value);
|
|
||||||
}
|
|
||||||
function formatCurrencyValue(value) {
|
|
||||||
if (value === null) {
|
|
||||||
return void 0;
|
|
||||||
}
|
|
||||||
return `\xA5${value.toLocaleString("en-US", {
|
|
||||||
maximumFractionDigits: 0
|
|
||||||
})}`;
|
|
||||||
}
|
|
||||||
function formatWanValue(value) {
|
|
||||||
if (value === null) {
|
|
||||||
return void 0;
|
|
||||||
}
|
|
||||||
return `${formatDecimalWithGrouping(value / 1e4)}w`;
|
|
||||||
}
|
|
||||||
function formatFractionPercent(value) {
|
|
||||||
if (value === null) {
|
|
||||||
return void 0;
|
|
||||||
}
|
|
||||||
return `${formatDecimalDisplay(value * 100)}%`;
|
|
||||||
}
|
|
||||||
function formatDecimalDisplay(value) {
|
|
||||||
if (value === null) {
|
|
||||||
return void 0;
|
|
||||||
}
|
|
||||||
return value.toLocaleString("en-US", {
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
useGrouping: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function formatDecimalWithGrouping(value) {
|
|
||||||
return value.toLocaleString("en-US", {
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
minimumFractionDigits: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function readNumericValue(value) {
|
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const trimmedValue = value.trim();
|
|
||||||
if (!trimmedValue) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parsedValue = Number(trimmedValue);
|
|
||||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function readStringArray(value) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.filter((item) => typeof item === "string");
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
try {
|
|
||||||
const parsedValue = JSON.parse(value);
|
|
||||||
return Array.isArray(parsedValue) ? parsedValue.filter((item) => typeof item === "string") : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
function readArrayLike(value) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
try {
|
|
||||||
const parsedValue = JSON.parse(value);
|
|
||||||
return Array.isArray(parsedValue) ? parsedValue : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
function readRecordLike(value) {
|
|
||||||
if (isRecord(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
try {
|
|
||||||
const parsedValue = JSON.parse(value);
|
|
||||||
return isRecord(parsedValue) ? parsedValue : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function readKnownNumber(record, keys) {
|
|
||||||
for (const key of keys) {
|
|
||||||
const value = readNumericValue(record[key]);
|
|
||||||
if (value !== null) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return void 0;
|
|
||||||
}
|
|
||||||
function readKnownNumberDeep(value, keys) {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const directValue = readKnownNumber(value, keys);
|
|
||||||
if (typeof directValue === "number") {
|
|
||||||
return directValue;
|
|
||||||
}
|
|
||||||
for (const nestedValue of Object.values(value)) {
|
|
||||||
const candidate = readKnownNumberDeep(unwrapVueRef(nestedValue), keys);
|
|
||||||
if (typeof candidate === "number") {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/content/market/page-bridge.ts
|
|
||||||
var BRIDGE_MARKER = "__SCES_MARKET_PAGE_BRIDGE_INSTALLED__";
|
|
||||||
var MARKET_SEARCH_REQUEST_PATH = "/gw/api/gsearch/search_for_author_square";
|
|
||||||
var SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
|
|
||||||
installMarketPageBridge();
|
|
||||||
function installMarketPageBridge() {
|
|
||||||
if (window[BRIDGE_MARKER]) {
|
|
||||||
syncSerializedMarketRows();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window[BRIDGE_MARKER] = true;
|
|
||||||
installMarketRequestSnapshotBridge();
|
|
||||||
syncSerializedMarketRows();
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
syncSerializedMarketRows();
|
|
||||||
});
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true
|
|
||||||
});
|
|
||||||
window.setInterval(() => {
|
|
||||||
syncSerializedMarketRows();
|
|
||||||
}, 1e3);
|
|
||||||
}
|
|
||||||
function installMarketRequestSnapshotBridge() {
|
|
||||||
installFetchSnapshotBridge();
|
|
||||||
installXmlHttpRequestSnapshotBridge();
|
|
||||||
}
|
|
||||||
function syncSerializedMarketRows() {
|
|
||||||
if (typeof document === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextSerializedRows = JSON.stringify(readSerializedMarketRows());
|
|
||||||
if (document.documentElement.getAttribute(SERIALIZED_MARKET_ROWS_ATTRIBUTE) !== nextSerializedRows) {
|
|
||||||
document.documentElement.setAttribute(
|
|
||||||
SERIALIZED_MARKET_ROWS_ATTRIBUTE,
|
|
||||||
nextSerializedRows
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function installFetchSnapshotBridge() {
|
|
||||||
if (typeof window.fetch !== "function") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const originalFetch = window.fetch.bind(window);
|
|
||||||
window.fetch = async (input, init) => {
|
|
||||||
const requestSnapshot = readFetchSnapshot(input, init);
|
|
||||||
const response = await originalFetch(input, init);
|
|
||||||
if (requestSnapshot) {
|
|
||||||
const clonedResponse = response.clone();
|
|
||||||
void captureMarketSnapshotFromResponse(
|
|
||||||
requestSnapshot,
|
|
||||||
() => clonedResponse.json()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function installXmlHttpRequestSnapshotBridge() {
|
|
||||||
const OriginalXmlHttpRequest = window.XMLHttpRequest;
|
|
||||||
if (!OriginalXmlHttpRequest) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const originalOpen = OriginalXmlHttpRequest.prototype.open;
|
|
||||||
const originalSend = OriginalXmlHttpRequest.prototype.send;
|
|
||||||
const originalSetRequestHeader = OriginalXmlHttpRequest.prototype.setRequestHeader;
|
|
||||||
OriginalXmlHttpRequest.prototype.open = function(method, url, ...rest) {
|
|
||||||
this.__scesMarketSnapshot = {
|
|
||||||
headers: {},
|
|
||||||
method,
|
|
||||||
url: String(url)
|
|
||||||
};
|
|
||||||
return originalOpen.call(this, method, url, ...rest);
|
|
||||||
};
|
|
||||||
OriginalXmlHttpRequest.prototype.setRequestHeader = function(name, value) {
|
|
||||||
this.__scesMarketSnapshot?.headers && (this.__scesMarketSnapshot.headers[name] = value);
|
|
||||||
return originalSetRequestHeader.call(this, name, value);
|
|
||||||
};
|
|
||||||
OriginalXmlHttpRequest.prototype.send = function(body) {
|
|
||||||
const snapshotState = this.__scesMarketSnapshot;
|
|
||||||
if (snapshotState) {
|
|
||||||
snapshotState.body = typeof body === "string" ? body : void 0;
|
|
||||||
this.addEventListener("load", () => {
|
|
||||||
if (this.status < 200 || this.status >= 300 || typeof this.responseText !== "string") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void captureMarketSnapshotFromResponse(
|
|
||||||
snapshotState,
|
|
||||||
async () => JSON.parse(this.responseText)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return originalSend.call(this, body);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
async function captureMarketSnapshotFromResponse(snapshot, readPayload) {
|
|
||||||
if (!isMarketSearchRequest(snapshot.url)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const payload = await readPayload();
|
|
||||||
if (!parseMarketListResponse(payload)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
writeMarketListRequestSnapshot(document, {
|
|
||||||
body: snapshot.body,
|
|
||||||
headers: snapshot.headers,
|
|
||||||
method: snapshot.method,
|
|
||||||
url: snapshot.url
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function readSerializedMarketRows() {
|
|
||||||
const marketList = readMarketList();
|
|
||||||
return marketList.map((row) => {
|
|
||||||
const attributeDatas = isRecord2(row.attribute_datas) ? row.attribute_datas : {};
|
|
||||||
const singleVideoAfterSearchRate = readNormalizedFractionRate(
|
|
||||||
attributeDatas.avg_search_after_view_rate_30d
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
authorId: readString2(row.star_id) ?? readString2(attributeDatas.id) ?? "",
|
|
||||||
authorName: readString2(attributeDatas.nickname) ?? readString2(row.nick_name) ?? "",
|
|
||||||
coreUserId: readString2(attributeDatas.core_user_id) ?? void 0,
|
|
||||||
singleVideoAfterSearchRate
|
|
||||||
};
|
|
||||||
}).filter((row) => Boolean(row.authorId || row.authorName));
|
|
||||||
}
|
|
||||||
function readFetchSnapshot(input, init) {
|
|
||||||
const request = input instanceof Request ? input : null;
|
|
||||||
const method = init?.method ?? request?.method ?? "GET";
|
|
||||||
const url = request?.url ?? String(input);
|
|
||||||
const body = typeof init?.body === "string" ? init.body : typeof request?.bodyUsed === "boolean" && request.bodyUsed ? void 0 : void 0;
|
|
||||||
const headers = serializeHeaders(init?.headers ?? request?.headers);
|
|
||||||
return {
|
|
||||||
body,
|
|
||||||
headers,
|
|
||||||
method,
|
|
||||||
url
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function serializeHeaders(headers) {
|
|
||||||
if (!headers) {
|
|
||||||
return void 0;
|
|
||||||
}
|
|
||||||
if (headers instanceof Headers) {
|
|
||||||
return Object.fromEntries(headers.entries());
|
|
||||||
}
|
|
||||||
if (Array.isArray(headers)) {
|
|
||||||
return Object.fromEntries(headers);
|
|
||||||
}
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(headers).map(([key, value]) => [key, String(value)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function readMarketList() {
|
|
||||||
if (typeof document === "undefined") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const marketRoot = document.querySelector(".base-author-list");
|
|
||||||
const setupState = marketRoot?.__vue__?._setupState;
|
|
||||||
if (!setupState) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
for (const value of Object.values(setupState)) {
|
|
||||||
const candidate = unwrapVueRef2(value);
|
|
||||||
if (Array.isArray(candidate) && looksLikeMarketList(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
if (!isRecord2(candidate) || !Array.isArray(candidate.marketList)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (looksLikeMarketList(candidate.marketList)) {
|
|
||||||
return candidate.marketList;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
function isMarketSearchRequest(url) {
|
|
||||||
return url === MARKET_SEARCH_REQUEST_PATH || url.startsWith(`${MARKET_SEARCH_REQUEST_PATH}?`) || url.includes(`${MARKET_SEARCH_REQUEST_PATH}?`) || url.endsWith(MARKET_SEARCH_REQUEST_PATH);
|
|
||||||
}
|
|
||||||
function looksLikeMarketList(value) {
|
|
||||||
const firstRow = value[0];
|
|
||||||
return isRecord2(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow);
|
|
||||||
}
|
|
||||||
function unwrapVueRef2(value) {
|
|
||||||
if (isRecord2(value) && "value" in value) {
|
|
||||||
return value.value;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
function isRecord2(value) {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
function readString2(value) {
|
|
||||||
return typeof value === "string" ? value : null;
|
|
||||||
}
|
|
||||||
function readNormalizedFractionRate(value) {
|
|
||||||
return typeof value === "string" ? normalizeFractionRateDisplay(value) ?? void 0 : void 0;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"action": {
|
|
||||||
"default_icon": {
|
|
||||||
"16": "assets/icons/icon-16.png",
|
|
||||||
"32": "assets/icons/icon-32.png"
|
|
||||||
},
|
|
||||||
"default_popup": "popup/index.html"
|
|
||||||
},
|
|
||||||
"background": {
|
|
||||||
"service_worker": "background/index.js"
|
|
||||||
},
|
|
||||||
"content_scripts": [
|
|
||||||
{
|
|
||||||
"js": [
|
|
||||||
"content/index.js"
|
|
||||||
],
|
|
||||||
"matches": [
|
|
||||||
"https://xingtu.cn/ad/creator/market*",
|
|
||||||
"https://*.xingtu.cn/ad/creator/market*"
|
|
||||||
],
|
|
||||||
"run_at": "document_start"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Bootstraps the Xingtu creator market content script.",
|
|
||||||
"icons": {
|
|
||||||
"16": "assets/icons/icon-16.png",
|
|
||||||
"32": "assets/icons/icon-32.png",
|
|
||||||
"48": "assets/icons/icon-48.png",
|
|
||||||
"128": "assets/icons/icon-128.png"
|
|
||||||
},
|
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0CaZJxcX97TbRXCR08L10t9EZFV31+wPnUgDf21j2f0qYaWdblzWXfVkeU9jGb2Hr2Etpp7F/XuBa6pcipUXkzMMBkJ42KOkciAwbuzTBoAtGB8o9aoWigtax+gGfSz+T3BjqxKBJtXqeqbIAKCDIlxRKIrY+KcY1Z+mD5BKcBHKsUDQPlHsrjc1g0wIBD5doz9LoOk1Wso6gK5cSeOp9lw5YHcu4TImR4yqxGiL6pZwnpciuX/g7qjWBZXn5gf0YBlDsBDDTt5upbP3NguUKgO2qA9M77LyeUwXl3aqbIxYi/VwsQ2t5w9PGWtnOUQQDWUcEg/9dfTb89esZXKATwIDAQAB",
|
|
||||||
"manifest_version": 3,
|
|
||||||
"name": "Star Chart Search Enhancer",
|
|
||||||
"permissions": [
|
|
||||||
"downloads",
|
|
||||||
"identity",
|
|
||||||
"storage"
|
|
||||||
],
|
|
||||||
"web_accessible_resources": [
|
|
||||||
{
|
|
||||||
"matches": [
|
|
||||||
"https://xingtu.cn/*",
|
|
||||||
"https://*.xingtu.cn/*"
|
|
||||||
],
|
|
||||||
"resources": [
|
|
||||||
"content/market-page-bridge.js"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version": "0.0525.6",
|
|
||||||
"host_permissions": [
|
|
||||||
"https://xingtu.cn/ad/creator/market*",
|
|
||||||
"https://*.xingtu.cn/ad/creator/market*",
|
|
||||||
"https://login-api.intelligrow.cn/*",
|
|
||||||
"https://talent-search.intelligrow.cn/*",
|
|
||||||
"http://192.168.31.21:8083/*",
|
|
||||||
"https://*/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Star Chart Search Enhancer</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main id="app"></main>
|
|
||||||
<script src="./index.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,422 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
(() => {
|
|
||||||
// src/popup/view.ts
|
|
||||||
function renderLoggedOut(root, error) {
|
|
||||||
root.innerHTML = `
|
|
||||||
<section data-popup-state="logged-out">
|
|
||||||
<h1>Star Chart Search Enhancer</h1>
|
|
||||||
<p>\u767B\u5F55\u540E\u624D\u80FD\u4F7F\u7528\u661F\u56FE\u589E\u5F3A\u529F\u80FD</p>
|
|
||||||
${error ? `<p data-popup-error="true">${error}</p>` : ""}
|
|
||||||
<button type="button" data-popup-sign-in="button">\u767B\u5F55 Logto</button>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
function renderLoggedIn(root, authState) {
|
|
||||||
const userInfo = authState.userInfo;
|
|
||||||
root.innerHTML = `
|
|
||||||
<section data-popup-state="logged-in">
|
|
||||||
<h1>Star Chart Search Enhancer</h1>
|
|
||||||
<p>\u5DF2\u767B\u5F55</p>
|
|
||||||
<p>${userInfo?.name ?? userInfo?.username ?? "\u672A\u77E5\u7528\u6237"}</p>
|
|
||||||
<p>${userInfo?.email ?? ""}</p>
|
|
||||||
<section data-popup-update="root">
|
|
||||||
<h2>\u7248\u672C\u66F4\u65B0</h2>
|
|
||||||
<p data-popup-update-status="text">\u6B63\u5728\u68C0\u67E5\u66F4\u65B0...</p>
|
|
||||||
</section>
|
|
||||||
<button type="button" data-popup-sign-out="button">\u9000\u51FA\u767B\u5F55</button>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
function renderUpdateStatus(root, options) {
|
|
||||||
const container = root.querySelector('[data-popup-update="root"]');
|
|
||||||
if (!container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (options.status === "checking") {
|
|
||||||
container.innerHTML = `
|
|
||||||
<h2>\u7248\u672C\u66F4\u65B0</h2>
|
|
||||||
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
|
|
||||||
<p>\u6B63\u5728\u68C0\u67E5\u66F4\u65B0...</p>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (options.status === "error") {
|
|
||||||
container.innerHTML = `
|
|
||||||
<h2>\u7248\u672C\u66F4\u65B0</h2>
|
|
||||||
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
|
|
||||||
<p>\u6682\u65F6\u65E0\u6CD5\u68C0\u67E5\u66F4\u65B0</p>
|
|
||||||
<p>\u5982\u679C\u9700\u8981\u65B0\u7248\uFF0C\u8BF7\u8054\u7CFB\u7EF4\u62A4\u540C\u4E8B\u83B7\u53D6\u66F4\u65B0\u5305\u3002</p>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (options.status === "latest" || !options.manifest) {
|
|
||||||
container.innerHTML = `
|
|
||||||
<h2>\u7248\u672C\u66F4\u65B0</h2>
|
|
||||||
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
|
|
||||||
<p>\u5F53\u524D\u5DF2\u662F\u6700\u65B0\u7248\u672C</p>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = `
|
|
||||||
<h2>\u7248\u672C\u66F4\u65B0</h2>
|
|
||||||
<p data-popup-update-status="text">\u5F53\u524D\u7248\u672C\uFF1A${options.currentVersion}</p>
|
|
||||||
<p>\u53D1\u73B0\u65B0\u7248\u672C\uFF1A${options.manifest.latestVersion}</p>
|
|
||||||
${renderReleaseNotes(options.manifest.releaseNotes)}
|
|
||||||
<button type="button" data-popup-download-update="button">\u4E0B\u8F7D\u66F4\u65B0\u5305</button>
|
|
||||||
<button type="button" data-popup-download-guide="button">\u4E0B\u8F7D\u4F7F\u7528\u8BF4\u660E</button>
|
|
||||||
<p data-popup-update-download-status="text">\u4E0B\u8F7D\u540E\u8BF7\u89E3\u538B\u65B0\u7248 zip\uFF0C\u5E76\u5728 chrome://extensions \u91CC\u91CD\u65B0\u52A0\u8F7D\u63D2\u4EF6\u3002</p>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
function setUpdateDownloadStatus(root, value) {
|
|
||||||
const output = root.querySelector('[data-popup-update-download-status="text"]');
|
|
||||||
if (!output) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
output.textContent = value;
|
|
||||||
}
|
|
||||||
function renderReleaseNotes(releaseNotes) {
|
|
||||||
if (releaseNotes.length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return `
|
|
||||||
<ul>
|
|
||||||
${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
|
|
||||||
</ul>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
||||||
}
|
|
||||||
function renderDevPanel(root, authState) {
|
|
||||||
const panel = root.ownerDocument.createElement("section");
|
|
||||||
panel.dataset.popupDevPanel = "root";
|
|
||||||
panel.innerHTML = `
|
|
||||||
<h2>dev auth panel</h2>
|
|
||||||
<p>resource: ${authState.resource ?? ""}</p>
|
|
||||||
<p>scopes: ${(authState.scopes ?? []).join(", ")}</p>
|
|
||||||
<p>token: ${authState.tokenAvailable ? "available" : "missing"}</p>
|
|
||||||
<p>expires: ${authState.accessTokenExpiresAt ?? "unknown"}</p>
|
|
||||||
<p>error: ${authState.lastError ?? ""}</p>
|
|
||||||
<button type="button" data-popup-test-protected-api="button">\u6D4B\u8BD5\u53D7\u4FDD\u62A4\u63A5\u53E3</button>
|
|
||||||
<pre data-popup-protected-api-result="output"></pre>
|
|
||||||
`;
|
|
||||||
root.appendChild(panel);
|
|
||||||
}
|
|
||||||
function setProtectedApiResult(root, value) {
|
|
||||||
const output = root.querySelector(
|
|
||||||
'[data-popup-protected-api-result="output"]'
|
|
||||||
);
|
|
||||||
if (!output) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
output.textContent = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/shared/auth-config.ts
|
|
||||||
var defaultAuthConfig = {
|
|
||||||
apiResource: "https://talent-search.intelligrow.cn",
|
|
||||||
appId: "i4jkllbvih0554r4n0fd3",
|
|
||||||
enableDevAuthPanel: false,
|
|
||||||
logtoEndpoint: "https://login-api.intelligrow.cn",
|
|
||||||
scopes: ["openid", "profile", "offline_access", "talent-search:read"]
|
|
||||||
};
|
|
||||||
function readAuthConfig(overrides = {}) {
|
|
||||||
const nextConfig = {
|
|
||||||
...defaultAuthConfig,
|
|
||||||
...overrides
|
|
||||||
};
|
|
||||||
if (!nextConfig.logtoEndpoint.trim()) {
|
|
||||||
throw new Error("auth config logtoEndpoint is required");
|
|
||||||
}
|
|
||||||
if (!nextConfig.appId.trim()) {
|
|
||||||
throw new Error("auth config appId is required");
|
|
||||||
}
|
|
||||||
if (!nextConfig.apiResource.trim()) {
|
|
||||||
throw new Error("auth config apiResource is required");
|
|
||||||
}
|
|
||||||
return nextConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/shared/auth-messages.ts
|
|
||||||
function isAuthResponseMessage(value) {
|
|
||||||
if (!value || typeof value !== "object") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const candidate = value;
|
|
||||||
if (candidate.ok === false) {
|
|
||||||
return candidate.type === "auth:error" && typeof candidate.error === "string";
|
|
||||||
}
|
|
||||||
if (candidate.ok !== true || typeof candidate.type !== "string") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (candidate.type === "auth:ack") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (candidate.type === "auth:token") {
|
|
||||||
return Boolean(
|
|
||||||
candidate.value && typeof candidate.value === "object" && typeof candidate.value.accessToken === "string"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (candidate.type === "auth:state") {
|
|
||||||
return Boolean(
|
|
||||||
candidate.value && typeof candidate.value === "object" && typeof candidate.value.isAuthenticated === "boolean"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/shared/protected-api-client.ts
|
|
||||||
function createProtectedApiClient(options) {
|
|
||||||
const fetchImpl = options.fetchImpl ?? fetch;
|
|
||||||
return {
|
|
||||||
async loadProtectedMockData() {
|
|
||||||
const token = await readAccessToken(options.sendMessage);
|
|
||||||
const response = await fetchImpl(
|
|
||||||
new URL("/api/mock/protected", options.baseUrl).toString(),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
},
|
|
||||||
method: "GET"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
throw new Error("protected api unauthorized");
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`protected api request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
async function readAccessToken(sendMessage) {
|
|
||||||
const response = await sendMessage({ type: "auth:get-access-token" });
|
|
||||||
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:token" || !response.value.accessToken.trim()) {
|
|
||||||
throw new Error("protected api token unavailable");
|
|
||||||
}
|
|
||||||
return response.value.accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/shared/update-check.ts
|
|
||||||
function compareExtensionVersions(left, right) {
|
|
||||||
const leftParts = parseVersionParts(left);
|
|
||||||
const rightParts = parseVersionParts(right);
|
|
||||||
const maxLength = Math.max(leftParts.length, rightParts.length);
|
|
||||||
for (let index = 0; index < maxLength; index += 1) {
|
|
||||||
const leftValue = leftParts[index] ?? 0;
|
|
||||||
const rightValue = rightParts[index] ?? 0;
|
|
||||||
if (leftValue !== rightValue) {
|
|
||||||
return leftValue - rightValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
function parseUpdateManifest(value) {
|
|
||||||
if (!value || typeof value !== "object") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const candidate = value;
|
|
||||||
if (!isVersionString(candidate.latestVersion) || !isVersionString(candidate.minSupportedVersion) || !isHttpsUrl(candidate.zipUrl) || !isHttpsUrl(candidate.guideUrl) || typeof candidate.publishedAt !== "string" || !Array.isArray(candidate.releaseNotes) || !candidate.releaseNotes.every((note) => typeof note === "string")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
guideUrl: candidate.guideUrl,
|
|
||||||
latestVersion: candidate.latestVersion,
|
|
||||||
minSupportedVersion: candidate.minSupportedVersion,
|
|
||||||
publishedAt: candidate.publishedAt,
|
|
||||||
releaseNotes: candidate.releaseNotes,
|
|
||||||
zipUrl: candidate.zipUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
async function fetchUpdateManifest(manifestUrl, fetchImpl = fetch) {
|
|
||||||
const response = await fetchImpl(manifestUrl, {
|
|
||||||
cache: "no-store"
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`update manifest request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
const manifest = parseUpdateManifest(await response.json());
|
|
||||||
if (!manifest) {
|
|
||||||
throw new Error("update manifest is invalid");
|
|
||||||
}
|
|
||||||
return manifest;
|
|
||||||
}
|
|
||||||
function parseVersionParts(value) {
|
|
||||||
return value.split(".").map((part) => {
|
|
||||||
const parsed = Number.parseInt(part, 10);
|
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function isVersionString(value) {
|
|
||||||
return typeof value === "string" && /^\d+(?:\.\d+)*$/.test(value);
|
|
||||||
}
|
|
||||||
function isHttpsUrl(value) {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return new URL(value).protocol === "https:";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/shared/update-config.ts
|
|
||||||
var UPDATE_MANIFEST_URL = "https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json";
|
|
||||||
|
|
||||||
// src/popup/index.ts
|
|
||||||
async function bootPopup(options = {}) {
|
|
||||||
const currentDocument = options.document ?? document;
|
|
||||||
const popupConfig = readAuthConfig(options.config);
|
|
||||||
const currentVersion = options.currentVersion ?? readCurrentVersion();
|
|
||||||
const root = currentDocument.querySelector("#app");
|
|
||||||
const HTMLElementCtor = currentDocument.defaultView?.HTMLElement;
|
|
||||||
if (!root || HTMLElementCtor && !(root instanceof HTMLElementCtor)) {
|
|
||||||
throw new Error("popup root #app is required");
|
|
||||||
}
|
|
||||||
const sendMessage = options.sendMessage ?? ((message) => Promise.resolve(
|
|
||||||
globalThis.chrome?.runtime?.sendMessage?.(message)
|
|
||||||
));
|
|
||||||
const fetchProtectedApi = options.fetchProtectedApi ?? createProtectedApiClient({
|
|
||||||
baseUrl: "http://127.0.0.1:4319",
|
|
||||||
sendMessage
|
|
||||||
}).loadProtectedMockData;
|
|
||||||
const fetchUpdateManifest2 = options.fetchUpdateManifest ?? (() => fetchUpdateManifest(
|
|
||||||
options.updateManifestUrl ?? UPDATE_MANIFEST_URL
|
|
||||||
));
|
|
||||||
await renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, {
|
|
||||||
currentVersion,
|
|
||||||
fetchUpdateManifest: fetchUpdateManifest2
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function renderCurrentAuthState(root, popupConfig, sendMessage, fetchProtectedApi, updateOptions) {
|
|
||||||
const response = await sendMessage({ type: "auth:get-state" });
|
|
||||||
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
|
|
||||||
renderLoggedOut(root, "\u8BA4\u8BC1\u72B6\u6001\u8BFB\u53D6\u5931\u8D25");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.value.isAuthenticated) {
|
|
||||||
renderLoggedOut(root, response.value.lastError);
|
|
||||||
root.querySelector('[data-popup-sign-in="button"]')?.addEventListener("click", () => {
|
|
||||||
void runAuthAction(root, popupConfig, sendMessage, {
|
|
||||||
actionMessage: { type: "auth:sign-in" },
|
|
||||||
fetchProtectedApi,
|
|
||||||
updateOptions
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderLoggedIn(root, response.value);
|
|
||||||
void runUpdateCheck(root, sendMessage, updateOptions);
|
|
||||||
root.querySelector('[data-popup-sign-out="button"]')?.addEventListener("click", () => {
|
|
||||||
void runAuthAction(root, popupConfig, sendMessage, {
|
|
||||||
actionMessage: { type: "auth:sign-out" },
|
|
||||||
fetchProtectedApi,
|
|
||||||
updateOptions
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (popupConfig.enableDevAuthPanel) {
|
|
||||||
renderDevPanel(root, response.value);
|
|
||||||
root.querySelector('[data-popup-test-protected-api="button"]')?.addEventListener("click", () => {
|
|
||||||
void runProtectedApiProbe(root, fetchProtectedApi);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function runAuthAction(root, popupConfig, sendMessage, options) {
|
|
||||||
const response = await sendMessage(options.actionMessage);
|
|
||||||
if (isActionError(response)) {
|
|
||||||
renderLoggedOut(root, response.error);
|
|
||||||
root.querySelector('[data-popup-sign-in="button"]')?.addEventListener("click", () => {
|
|
||||||
void runAuthAction(root, popupConfig, sendMessage, options);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await renderCurrentAuthState(
|
|
||||||
root,
|
|
||||||
popupConfig,
|
|
||||||
sendMessage,
|
|
||||||
options.fetchProtectedApi,
|
|
||||||
options.updateOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function isActionError(response) {
|
|
||||||
return isAuthResponseMessage(response) && !response.ok && response.type === "auth:error";
|
|
||||||
}
|
|
||||||
async function runUpdateCheck(root, sendMessage, options) {
|
|
||||||
renderUpdateStatus(root, {
|
|
||||||
currentVersion: options.currentVersion,
|
|
||||||
status: "checking"
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const manifest = await options.fetchUpdateManifest();
|
|
||||||
if (compareExtensionVersions(manifest.latestVersion, options.currentVersion) <= 0) {
|
|
||||||
renderUpdateStatus(root, {
|
|
||||||
currentVersion: options.currentVersion,
|
|
||||||
status: "latest"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderUpdateStatus(root, {
|
|
||||||
currentVersion: options.currentVersion,
|
|
||||||
manifest,
|
|
||||||
status: "available"
|
|
||||||
});
|
|
||||||
bindUpdateDownloadButtons(root, sendMessage, manifest);
|
|
||||||
} catch {
|
|
||||||
renderUpdateStatus(root, {
|
|
||||||
currentVersion: options.currentVersion,
|
|
||||||
status: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function bindUpdateDownloadButtons(root, sendMessage, manifest) {
|
|
||||||
root.querySelector('[data-popup-download-update="button"]')?.addEventListener("click", () => {
|
|
||||||
void downloadUpdateAsset(root, sendMessage, {
|
|
||||||
filename: "star-chart-search-enhancer-internal.zip",
|
|
||||||
url: manifest.zipUrl
|
|
||||||
});
|
|
||||||
});
|
|
||||||
root.querySelector('[data-popup-download-guide="button"]')?.addEventListener("click", () => {
|
|
||||||
void downloadUpdateAsset(root, sendMessage, {
|
|
||||||
filename: "\u661F\u56FE\u589E\u5F3A\u63D2\u4EF6-\u8D85\u7B80\u5355\u5B89\u88C5\u4F7F\u7528\u6307\u5357.pdf",
|
|
||||||
url: manifest.guideUrl
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function downloadUpdateAsset(root, sendMessage, options) {
|
|
||||||
setUpdateDownloadStatus(root, "\u6B63\u5728\u4E0B\u8F7D...");
|
|
||||||
try {
|
|
||||||
await sendMessage({
|
|
||||||
filename: options.filename,
|
|
||||||
type: "update:download",
|
|
||||||
url: options.url
|
|
||||||
});
|
|
||||||
setUpdateDownloadStatus(root, "\u5DF2\u89E6\u53D1\u4E0B\u8F7D\u3002\u4E0B\u8F7D\u540E\u8BF7\u89E3\u538B\u65B0\u7248 zip\uFF0C\u5E76\u5728 chrome://extensions \u91CC\u91CD\u65B0\u52A0\u8F7D\u63D2\u4EF6\u3002");
|
|
||||||
} catch (error) {
|
|
||||||
setUpdateDownloadStatus(
|
|
||||||
root,
|
|
||||||
error instanceof Error ? error.message : "\u4E0B\u8F7D\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function readCurrentVersion() {
|
|
||||||
const runtime = globalThis.chrome?.runtime;
|
|
||||||
return runtime?.getManifest?.().version ?? "0.0.0";
|
|
||||||
}
|
|
||||||
async function runProtectedApiProbe(root, fetchProtectedApi) {
|
|
||||||
setProtectedApiResult(root, "\u8BF7\u6C42\u4E2D...");
|
|
||||||
try {
|
|
||||||
const result = await fetchProtectedApi();
|
|
||||||
setProtectedApiResult(root, JSON.stringify(result, null, 2));
|
|
||||||
} catch (error) {
|
|
||||||
setProtectedApiResult(
|
|
||||||
root,
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof document !== "undefined") {
|
|
||||||
void bootPopup();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@ -73,9 +73,23 @@ The pipeline uses the tag as the release version. Recommended format: `0.MMDD.N`
|
|||||||
2. Open `chrome://extensions`.
|
2. Open `chrome://extensions`.
|
||||||
3. Enable developer mode.
|
3. Enable developer mode.
|
||||||
4. Click `Load unpacked`.
|
4. Click `Load unpacked`.
|
||||||
5. Select the unzipped folder.
|
5. Select the unzipped `dist/` folder.
|
||||||
6. Confirm the extension ID is `pkjopdibdnomhogjheclhnknmejccffg`.
|
6. Confirm the extension ID is `pkjopdibdnomhogjheclhnknmejccffg`.
|
||||||
|
|
||||||
|
## One-Time Bridge Upgrade
|
||||||
|
|
||||||
|
Some coworkers may still be using an older unpacked build whose popup cannot read the COS update manifest and only shows:
|
||||||
|
|
||||||
|
- `暂时无法检查更新`
|
||||||
|
|
||||||
|
For those users, ask them to do one manual bridge upgrade with the newest ZIP:
|
||||||
|
|
||||||
|
1. Download the newest `star-chart-search-enhancer-internal.zip`.
|
||||||
|
2. Unzip it and get the new `dist/` folder.
|
||||||
|
3. Re-load that `dist/` folder in `chrome://extensions`.
|
||||||
|
|
||||||
|
After this one-time bridge upgrade, future updates should continue using the same `dist/` layout.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Keep `.local/extension-key.pem` private and backed up internally.
|
- Keep `.local/extension-key.pem` private and backed up internally.
|
||||||
|
|||||||
179
docs/superpowers/plans/2026-05-25-popup-update-panel.md
Normal file
179
docs/superpowers/plans/2026-05-25-popup-update-panel.md
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
# Popup Update Panel Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Redesign the extension popup into a compact enterprise-style tool panel with clearer grouping, better spacing, and more readable update actions while keeping existing behavior unchanged.
|
||||||
|
|
||||||
|
**Architecture:** Keep the popup behavior in `src/popup/index.ts` and focus most UI changes in `src/popup/view.ts`. Add only the minimum structural changes needed to support grouped cards, clearer update states, and button hierarchy, then verify the existing popup tests still cover the update workflow.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Chrome MV3 popup UI, Vitest, jsdom
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Modify: `src/popup/view.ts`
|
||||||
|
- Replace the current long text flow with grouped cards and clearer status blocks.
|
||||||
|
- Modify: `src/popup/index.html`
|
||||||
|
- Add popup-level CSS for layout, spacing, typography, and button hierarchy.
|
||||||
|
- Modify: `src/popup/index.ts`
|
||||||
|
- Keep logic unchanged unless the new view structure needs small status-render support changes.
|
||||||
|
- Modify: `tests/popup-entry.test.ts`
|
||||||
|
- Update popup rendering expectations for the redesigned layout.
|
||||||
|
|
||||||
|
### Task 1: Lock the desired popup structure in tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/popup-entry.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing layout assertions**
|
||||||
|
|
||||||
|
Update the logged-in and update-available popup tests to assert the new structure exists. Add checks for:
|
||||||
|
|
||||||
|
- a compact product header
|
||||||
|
- an account card/root section
|
||||||
|
- an update card/root section
|
||||||
|
- a primary update button and secondary guide button
|
||||||
|
|
||||||
|
Use selectors that match the intended new DOM shape, for example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-popup-account="card"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-popup-update="card"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused popup test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/popup-entry.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the current popup view does not expose the new grouped structure.
|
||||||
|
|
||||||
|
### Task 2: Redesign the logged-in popup shell
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/popup/view.ts`
|
||||||
|
- Modify: `src/popup/index.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement the grouped popup shell**
|
||||||
|
|
||||||
|
Change the logged-in renderer so it produces:
|
||||||
|
|
||||||
|
- a compact header
|
||||||
|
- an account card
|
||||||
|
- an update card container
|
||||||
|
- a low-emphasis footer action area
|
||||||
|
|
||||||
|
Keep the same text content, just reorganized.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add popup CSS**
|
||||||
|
|
||||||
|
Add scoped CSS in `src/popup/index.html` for:
|
||||||
|
|
||||||
|
- wider popup body
|
||||||
|
- neutral background
|
||||||
|
- white cards
|
||||||
|
- consistent spacing
|
||||||
|
- smaller title scale
|
||||||
|
- primary / secondary / tertiary button styles
|
||||||
|
|
||||||
|
Do not add animation-heavy or branding-heavy styles.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-run popup tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/popup-entry.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS for structure-related checks, with any remaining failures isolated to update-state details.
|
||||||
|
|
||||||
|
### Task 3: Redesign update-state rendering
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/popup/view.ts`
|
||||||
|
- Modify: `src/popup/index.ts` (only if required)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write or update state-specific assertions**
|
||||||
|
|
||||||
|
Ensure tests cover:
|
||||||
|
|
||||||
|
- `checking` state shows a compact progress line
|
||||||
|
- `latest` state hides download actions
|
||||||
|
- `available` state shows current version, latest version, notes, and action buttons
|
||||||
|
- `error` state renders a readable warning block
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/popup-entry.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL on any states not yet aligned to the new view.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement state-specific card rendering**
|
||||||
|
|
||||||
|
Refactor `renderUpdateStatus()` to keep one card shell and swap state bodies inside it. Make the available-update state visually prominent but restrained.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/popup-entry.test.ts tests/update-check.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 4: Verify the popup still works end-to-end
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/popup/view.ts` if polish is needed
|
||||||
|
- Modify: `src/popup/index.html` if polish is needed
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the release popup**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:release
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS and updated popup assets written to `dist/`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the full test suite**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual smoke check**
|
||||||
|
|
||||||
|
Open the unpacked extension popup from `dist/` and confirm:
|
||||||
|
|
||||||
|
- title no longer wraps into a giant stacked block
|
||||||
|
- account status is easy to scan
|
||||||
|
- update information reads clearly
|
||||||
|
- buttons are visually distinct and not cramped
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/popup/view.ts src/popup/index.html src/popup/index.ts tests/popup-entry.test.ts
|
||||||
|
git commit -m "feat: redesign popup update panel"
|
||||||
|
```
|
||||||
|
|
||||||
188
docs/superpowers/plans/2026-05-25-unified-dist-distribution.md
Normal file
188
docs/superpowers/plans/2026-05-25-unified-dist-distribution.md
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
# Unified Dist Distribution Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make first-time install, Git-based install, ZIP-based install, and later updates all use the same unpacked extension directory named `dist/`.
|
||||||
|
|
||||||
|
**Architecture:** Standardize the extension output path through one shared build-path helper, then make ZIP packaging wrap that exact `dist/` directory, and finally update all teammate-facing docs to reference only that path. The release pipeline keeps its existing behavior, but the user-facing artifact shape becomes stable and singular.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Node.js ESM scripts, Chrome MV3 extension packaging, Vitest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Create: `scripts/build-output-path.mjs`
|
||||||
|
- Single source of truth for the unpacked extension output path.
|
||||||
|
- Modify: `scripts/build.mjs`
|
||||||
|
- Write release builds to `dist/`.
|
||||||
|
- Modify: `scripts/package-release.mjs`
|
||||||
|
- Package the shared `dist/` output instead of any alternate directory.
|
||||||
|
- Modify: `scripts/package-release-archive.mjs`
|
||||||
|
- Ensure ZIP output preserves a top-level `dist/` folder.
|
||||||
|
- Modify: `tests/package-release-archive.test.ts`
|
||||||
|
- Verify ZIP layout unpacks as `dist/...`.
|
||||||
|
- Create: `tests/build-output-path.test.ts`
|
||||||
|
- Verify the shared helper resolves `dist/`.
|
||||||
|
- Modify: `playwright.config.js`
|
||||||
|
- Load the extension from `dist/`.
|
||||||
|
- Modify: `e2e-tests/extension-load.spec.js`
|
||||||
|
- Verify files under `dist/`.
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Remove `dist-release` references and describe `dist/` as the only unpacked directory.
|
||||||
|
- Modify: `docs/internal-extension-distribution.md`
|
||||||
|
- Align the release flow language with `dist/`.
|
||||||
|
- Modify: `docs/【给同事】从Git下载使用说明.md`
|
||||||
|
- Instruct coworkers to load `dist/`.
|
||||||
|
- Modify: `.gitignore`
|
||||||
|
- Remove obsolete `dist-release/` ignore entry if no longer needed.
|
||||||
|
- Delete tracked directory: `dist-release/`
|
||||||
|
- Remove the confusing second unpacked extension directory from the repo.
|
||||||
|
|
||||||
|
### Task 1: Lock the single output path in tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/build-output-path.test.ts`
|
||||||
|
- Modify: `tests/package-release-archive.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing helper test**
|
||||||
|
|
||||||
|
Create a small test asserting both development and release builds resolve to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
path.join("/repo", "dist")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing ZIP layout assertion**
|
||||||
|
|
||||||
|
Update the archive test so it expects:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
"dist/hello.txt"
|
||||||
|
```
|
||||||
|
|
||||||
|
not a flat file list or any alternate top-level folder.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run focused tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/build-output-path.test.ts tests/package-release-archive.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL until the shared path helper and ZIP layout are implemented.
|
||||||
|
|
||||||
|
### Task 2: Standardize build and package scripts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/build-output-path.mjs`
|
||||||
|
- Modify: `scripts/build.mjs`
|
||||||
|
- Modify: `scripts/package-release.mjs`
|
||||||
|
- Modify: `scripts/package-release-archive.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement the shared output-path helper**
|
||||||
|
|
||||||
|
Add `resolveExtensionBuildDir(projectRoot, buildTarget)` and return `dist/` for both release and development flows.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the build script**
|
||||||
|
|
||||||
|
Replace any hard-coded directory switching logic so the release build writes into `dist/`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the package script**
|
||||||
|
|
||||||
|
Point ZIP packaging at the same `dist/` directory.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Keep ZIP layout stable**
|
||||||
|
|
||||||
|
Ensure the archive helper stores files under a top-level `dist/` directory inside the ZIP.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/build-output-path.test.ts tests/package-release-archive.test.ts
|
||||||
|
npm run build:release
|
||||||
|
npm run package:internal
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS, and the ZIP should unpack as `dist/...`.
|
||||||
|
|
||||||
|
### Task 3: Remove `dist-release` from tooling and docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `playwright.config.js`
|
||||||
|
- Modify: `e2e-tests/extension-load.spec.js`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `docs/internal-extension-distribution.md`
|
||||||
|
- Modify: `docs/【给同事】从Git下载使用说明.md`
|
||||||
|
- Modify: `.gitignore`
|
||||||
|
- Delete tracked directory: `dist-release/`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update tool references**
|
||||||
|
|
||||||
|
Point Playwright and debug paths at `dist/`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update teammate docs**
|
||||||
|
|
||||||
|
Make every install/update instruction reference only `dist/`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove the obsolete tracked directory**
|
||||||
|
|
||||||
|
Delete the committed `dist-release/` tree so future contributors cannot mistake it for the live extension directory.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify references**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "dist-release" README.md docs package.json scripts e2e-tests playwright.config.js src tests .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no remaining user-facing `dist-release` references.
|
||||||
|
|
||||||
|
### Task 4: Final verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify any of the above only if fixes are required
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run targeted tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- tests/build-output-path.test.ts tests/package-release-archive.test.ts tests/manifest.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rebuild and inspect the artifact**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:release
|
||||||
|
npm run package:internal
|
||||||
|
unzip -l release/star-chart-search-enhancer-internal.zip | sed -n '1,20p'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the archive contents start with `dist/...`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Full verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS, or if unrelated pre-existing failures remain, capture them explicitly before completion.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/build-output-path.mjs scripts/build.mjs scripts/package-release.mjs scripts/package-release-archive.mjs tests/build-output-path.test.ts tests/package-release-archive.test.ts README.md docs/internal-extension-distribution.md docs/【给同事】从Git下载使用说明.md playwright.config.js e2e-tests/extension-load.spec.js .gitignore
|
||||||
|
git commit -m "chore: unify extension distribution around dist"
|
||||||
|
```
|
||||||
|
|
||||||
100
docs/superpowers/specs/2026-05-25-popup-update-panel-design.md
Normal file
100
docs/superpowers/specs/2026-05-25-popup-update-panel-design.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# Popup Update Panel Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Redesign the extension popup so it feels like a compact internal tool instead of a stretched document. The new popup should prioritize readability, clear grouping, and obvious update actions while keeping the interaction model unchanged.
|
||||||
|
|
||||||
|
## Confirmed Direction
|
||||||
|
|
||||||
|
- Visual style: enterprise tool
|
||||||
|
- Primary problem to solve: information feels crowded and hard to read
|
||||||
|
- Layout freedom: use whatever structure best improves clarity
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This change only redesigns the popup presentation layer:
|
||||||
|
|
||||||
|
- improve popup layout
|
||||||
|
- improve spacing and typography
|
||||||
|
- improve button hierarchy
|
||||||
|
- improve update-status presentation
|
||||||
|
|
||||||
|
This change does not alter:
|
||||||
|
|
||||||
|
- auth flow
|
||||||
|
- update-check logic
|
||||||
|
- download behavior
|
||||||
|
- background message behavior
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
Use a grouped card layout instead of one long text column.
|
||||||
|
|
||||||
|
Recommended structure:
|
||||||
|
|
||||||
|
1. Header
|
||||||
|
- compact product title on one line
|
||||||
|
2. Account card
|
||||||
|
- login state
|
||||||
|
- current user name
|
||||||
|
3. Update card
|
||||||
|
- current version
|
||||||
|
- latest version or current status
|
||||||
|
- release notes
|
||||||
|
- primary and secondary action buttons
|
||||||
|
- short follow-up instruction text
|
||||||
|
4. Footer action
|
||||||
|
- low-emphasis sign-out button
|
||||||
|
|
||||||
|
## Visual Rules
|
||||||
|
|
||||||
|
- Increase popup width to a more usable fixed tool width.
|
||||||
|
- Use a soft neutral page background with white content cards.
|
||||||
|
- Reduce title size significantly from the current oversized stacked text.
|
||||||
|
- Use a clear type scale:
|
||||||
|
- product title: medium emphasis
|
||||||
|
- section title: medium emphasis
|
||||||
|
- status/version rows: normal emphasis
|
||||||
|
- helper text: smaller and lighter
|
||||||
|
- Keep the palette restrained and neutral.
|
||||||
|
- Make `下载更新包` the primary button.
|
||||||
|
- Make `下载使用说明` a secondary button.
|
||||||
|
- Make `退出登录` a tertiary or low-emphasis button.
|
||||||
|
|
||||||
|
## Update State Presentation
|
||||||
|
|
||||||
|
- `checking`: show a compact in-progress line inside the update card.
|
||||||
|
- `latest`: show a simple success-style status without action buttons.
|
||||||
|
- `available`: show a clear “发现新版本” status block with versions and actions.
|
||||||
|
- `error`: show a readable warning block instead of generic broken-looking text flow.
|
||||||
|
|
||||||
|
## Content Rules
|
||||||
|
|
||||||
|
- Keep product name to one visual line or two short lines max.
|
||||||
|
- Avoid large paragraphs in the popup.
|
||||||
|
- Release notes should stay as a compact bullet list.
|
||||||
|
- The post-download instruction should be one concise sentence.
|
||||||
|
- Version labels should align consistently so the user can compare current/latest quickly.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- Keep the existing popup DOM entrypoints and rendering flow.
|
||||||
|
- Focus changes in `src/popup/view.ts` first.
|
||||||
|
- Only adjust popup controller code if needed to support cleaner status rendering.
|
||||||
|
- Prefer CSS embedded in the popup HTML/view flow only as needed; do not expand scope into unrelated refactors.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Add or update popup rendering tests for:
|
||||||
|
|
||||||
|
- logged-in layout still renders correctly
|
||||||
|
- available update state still shows current/latest versions
|
||||||
|
- update action buttons still exist
|
||||||
|
- latest/error states still render expected text
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- new popup features
|
||||||
|
- star chart page banners
|
||||||
|
- animation-heavy UI
|
||||||
|
- branding-heavy marketing visuals
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
# Unified Dist Distribution Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make first-time installation and later updates use the exact same extension directory structure so coworkers never need to learn different paths or loading rules.
|
||||||
|
|
||||||
|
## Confirmed Direction
|
||||||
|
|
||||||
|
- The only user-facing unpacked extension directory should be `dist/`.
|
||||||
|
- First install and later update must produce the same directory name and shape.
|
||||||
|
- Coworkers should always load the same directory in `chrome://extensions`.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current delivery flow has created multiple mental models:
|
||||||
|
|
||||||
|
- sometimes coworkers are told to load `dist-release/`
|
||||||
|
- sometimes the ZIP extracts to a differently named top-level folder
|
||||||
|
- sometimes Git-based installation and ZIP-based installation do not look identical
|
||||||
|
|
||||||
|
This is not user-friendly and increases support cost.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This change standardizes the user-facing extension directory across:
|
||||||
|
|
||||||
|
- local release builds
|
||||||
|
- ZIP packaging
|
||||||
|
- Git-based teammate installation
|
||||||
|
- later update downloads
|
||||||
|
- installation and update documentation
|
||||||
|
|
||||||
|
This change does not alter:
|
||||||
|
|
||||||
|
- extension runtime behavior
|
||||||
|
- update-check logic itself
|
||||||
|
- popup auth flow
|
||||||
|
- COS manifest format
|
||||||
|
|
||||||
|
## Distribution Rule
|
||||||
|
|
||||||
|
There must be exactly one user-facing extension directory:
|
||||||
|
|
||||||
|
- `dist/`
|
||||||
|
|
||||||
|
This rule must hold in all paths:
|
||||||
|
|
||||||
|
1. Git install
|
||||||
|
- teammate runs the release build
|
||||||
|
- resulting unpacked extension directory is `dist/`
|
||||||
|
2. ZIP install
|
||||||
|
- teammate unzips the package
|
||||||
|
- resulting top-level extension directory is `dist/`
|
||||||
|
3. ZIP update
|
||||||
|
- teammate downloads the newer ZIP
|
||||||
|
- unzip result is also `dist/`
|
||||||
|
- teammate replaces the previous `dist/`
|
||||||
|
|
||||||
|
## Packaging Rule
|
||||||
|
|
||||||
|
The release ZIP should unpack into a single top-level folder named `dist/`.
|
||||||
|
|
||||||
|
Expected unpack result:
|
||||||
|
|
||||||
|
- `dist/manifest.json`
|
||||||
|
- `dist/background/`
|
||||||
|
- `dist/content/`
|
||||||
|
- `dist/popup/`
|
||||||
|
- `dist/assets/`
|
||||||
|
|
||||||
|
The ZIP must not unpack as:
|
||||||
|
|
||||||
|
- a flat file list
|
||||||
|
- `dist-release/`
|
||||||
|
- `star-chart-search-enhancer-internal/`
|
||||||
|
- any other user-facing top-level folder name
|
||||||
|
|
||||||
|
## Build Rule
|
||||||
|
|
||||||
|
Release builds should write the unpacked extension directly to `dist/`.
|
||||||
|
|
||||||
|
There should not be a second extension build directory that teammates might mistake as the correct one.
|
||||||
|
|
||||||
|
If internal scripts need a concept of “release build,” that should be represented by:
|
||||||
|
|
||||||
|
- environment/config
|
||||||
|
- release manifest values
|
||||||
|
- packaging flow
|
||||||
|
|
||||||
|
but not by exposing a second unpacked directory name to users.
|
||||||
|
|
||||||
|
## Documentation Rule
|
||||||
|
|
||||||
|
All teammate-facing docs must say the same thing:
|
||||||
|
|
||||||
|
- first install: load `dist/`
|
||||||
|
- later update: replace `dist/` and reload
|
||||||
|
|
||||||
|
No user-facing doc should mention `dist-release/`.
|
||||||
|
|
||||||
|
## CI / Release Rule
|
||||||
|
|
||||||
|
Drone and local release scripts must publish only one ZIP layout:
|
||||||
|
|
||||||
|
- unzip result is `dist/`
|
||||||
|
|
||||||
|
The release flow must not generate a user ZIP whose folder layout differs from the Git-based install path.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- `npm run build:release` writes the unpacked extension to `dist/`
|
||||||
|
- release ZIP unpacks into a top-level `dist/` folder
|
||||||
|
- first install and later update ZIPs unpack to the same structure
|
||||||
|
- coworker docs consistently reference `dist/`
|
||||||
|
- repository no longer contains a second tracked unpacked extension directory that conflicts with `dist/`
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- changing extension features
|
||||||
|
- changing the update notification UX
|
||||||
|
- switching away from unpacked-extension installation
|
||||||
|
- Chrome Web Store or enterprise force-install deployment
|
||||||
@ -40,10 +40,10 @@ git clone https://git.internal.intelligrow.cn/wangshaoqing/star-chart-search-enh
|
|||||||
4. 点击 **"加载已解压的扩展程序"**
|
4. 点击 **"加载已解压的扩展程序"**
|
||||||
5. 选择桌面上的这个路径:
|
5. 选择桌面上的这个路径:
|
||||||
```
|
```
|
||||||
star-chart-search-enhancer/dist-release/
|
star-chart-search-enhancer/dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ **重要**:必须选择 `dist-release` 这个子文件夹,不要选外层文件夹
|
⚠️ **重要**:必须选择 `dist` 这个子文件夹,不要选外层文件夹
|
||||||
|
|
||||||
✅ 安装成功!你会看到插件卡片。
|
✅ 安装成功!你会看到插件卡片。
|
||||||
|
|
||||||
@ -109,7 +109,23 @@ npm run build:release
|
|||||||
⚠️ **如果重新加载后还是旧版本**:
|
⚠️ **如果重新加载后还是旧版本**:
|
||||||
- 先点击插件卡片的 **"移除"** 删除旧版本
|
- 先点击插件卡片的 **"移除"** 删除旧版本
|
||||||
- 然后重新点击 **"加载已解压的扩展程序"**
|
- 然后重新点击 **"加载已解压的扩展程序"**
|
||||||
- 再次选择 `dist-release` 文件夹
|
- 再次选择 `dist` 文件夹
|
||||||
|
|
||||||
|
## ♻️ 老用户一次性升级说明
|
||||||
|
|
||||||
|
如果你之前已经装过比较早的旧版本,而且插件弹窗里一直显示:
|
||||||
|
|
||||||
|
- `暂时无法检查更新`
|
||||||
|
|
||||||
|
那通常说明你本地还是旧的更新机制,建议先手动完成一次升级:
|
||||||
|
|
||||||
|
1. 先获取我发出的最新压缩包
|
||||||
|
2. 解压后得到新的 `dist` 文件夹
|
||||||
|
3. 打开 `chrome://extensions`
|
||||||
|
4. 删除旧插件,或重新执行一次 **"加载已解压的扩展程序"**
|
||||||
|
5. 重新选择新的 `dist` 文件夹
|
||||||
|
|
||||||
|
完成这一次后,后续再更新时,就可以继续沿用统一的 `dist` 目录方式。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -118,11 +134,14 @@ npm run build:release
|
|||||||
**Q: 提示 "git 不是内部或外部命令"?**
|
**Q: 提示 "git 不是内部或外部命令"?**
|
||||||
A: Git 没装好,请先安装 Git。
|
A: Git 没装好,请先安装 Git。
|
||||||
|
|
||||||
**Q: 下载后找不到 dist-release 文件夹?**
|
**Q: 下载后找不到 dist 文件夹?**
|
||||||
A: 请确认下载的是最新版本,可以重新执行 `git pull`。
|
A: 请确认下载的是最新版本,可以重新执行 `git pull` 并重新执行 `npm run build:release`。
|
||||||
|
|
||||||
**Q: 加载后扩展 ID 不对?**
|
**Q: 加载后扩展 ID 不对?**
|
||||||
A: 请检查是否选择了 `dist-release` 文件夹,而不是外层文件夹。
|
A: 请检查是否选择了 `dist` 文件夹,而不是外层文件夹。
|
||||||
|
|
||||||
|
**Q: 我已经在用这个插件了,还需要再用压缩包更新一次吗?**
|
||||||
|
A: 不一定。只有那些当前弹窗仍然只显示 `暂时无法检查更新` 的旧用户,才建议手动用最新压缩包重新装一次 `dist` 来完成过桥升级。已经能正常发现新版本的同事,继续按普通更新流程走即可。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -18,9 +18,9 @@
|
|||||||
|
|
||||||
1. 在桌面上找到这个压缩包
|
1. 在桌面上找到这个压缩包
|
||||||
2. **右键** → 选择"解压到当前文件夹"(或"Extract Here")
|
2. **右键** → 选择"解压到当前文件夹"(或"Extract Here")
|
||||||
3. 会多出一个文件夹,名字类似 `star-chart-search-enhancer-internal`
|
3. 会多出一个文件夹,名字是 `dist`
|
||||||
|
|
||||||
⚠️ **重要**:这个文件夹要一直放在桌面,不要删、不要改名
|
⚠️ **重要**:这个 `dist` 文件夹要一直放在桌面,不要删、不要改名
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -37,9 +37,9 @@
|
|||||||
|
|
||||||
4. 点击左上角出现的 **"加载已解压的扩展程序"**
|
4. 点击左上角出现的 **"加载已解压的扩展程序"**
|
||||||
|
|
||||||
5. 选择刚才解压出来的插件文件夹
|
5. 选择刚才解压出来的 `dist` 文件夹
|
||||||
|
|
||||||
⚠️ **重要**:如果文件夹里能看到 `manifest.json`、`content`、`background`、`popup` 这些文件和文件夹,说明选对了。
|
⚠️ **重要**:如果 `dist` 文件夹里能看到 `manifest.json`、`content`、`background`、`popup` 这些文件和文件夹,说明选对了。
|
||||||
|
|
||||||
6. 看到绿色的插件卡片出现,就装好了!
|
6. 看到绿色的插件卡片出现,就装好了!
|
||||||
|
|
||||||
@ -122,7 +122,7 @@ https://xingtu.cn/ad/creator/market
|
|||||||
- 解压下载到的新版本 zip
|
- 解压下载到的新版本 zip
|
||||||
- 打开 `chrome://extensions`
|
- 打开 `chrome://extensions`
|
||||||
- 找到 `Star Chart Search Enhancer`
|
- 找到 `Star Chart Search Enhancer`
|
||||||
- 点击 **"重新加载"**,或重新选择解压后的新插件文件夹
|
- 点击 **"重新加载"**,或重新选择解压后的 `dist` 文件夹
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -138,7 +138,25 @@ https://xingtu.cn/ad/creator/market
|
|||||||
⚠️ **如果重新加载后还是旧版本**:
|
⚠️ **如果重新加载后还是旧版本**:
|
||||||
- 先点击插件卡片的 **"移除"** 删除旧版本
|
- 先点击插件卡片的 **"移除"** 删除旧版本
|
||||||
- 然后重新点击 **"加载已解压的扩展程序"**
|
- 然后重新点击 **"加载已解压的扩展程序"**
|
||||||
- 再次选择新解压出来的插件文件夹
|
- 再次选择新解压出来的 `dist` 文件夹
|
||||||
|
|
||||||
|
## ♻️ 老用户一次性升级说明
|
||||||
|
|
||||||
|
如果你之前安装的是较早的旧版本,并且插件弹窗里一直显示:
|
||||||
|
|
||||||
|
- `暂时无法检查更新`
|
||||||
|
|
||||||
|
那通常说明你本地还在使用“旧更新机制”的插件包。
|
||||||
|
|
||||||
|
这种情况下,需要**手动用一次最新压缩包升级**:
|
||||||
|
|
||||||
|
1. 下载最新的 `star-chart-search-enhancer-internal.zip`
|
||||||
|
2. 解压后得到新的 `dist` 文件夹
|
||||||
|
3. 打开 `chrome://extensions`
|
||||||
|
4. 删除旧插件,或重新点击 **"加载已解压的扩展程序"**
|
||||||
|
5. 重新选择新的 `dist` 文件夹
|
||||||
|
|
||||||
|
完成这一次“过桥升级”后,后面再看到新版本时,就可以继续按统一的 `dist` 更新方式操作。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -156,6 +174,9 @@ A: 检查浏览器的下载列表,文件可能已经下好了
|
|||||||
### Q: 不小心把文件夹删了?
|
### Q: 不小心把文件夹删了?
|
||||||
A: 重新解压压缩包,然后到 `chrome://extensions` 点"重新加载"
|
A: 重新解压压缩包,然后到 `chrome://extensions` 点"重新加载"
|
||||||
|
|
||||||
|
### Q: 我已经在用插件了,还需要再用一次压缩包更新吗?
|
||||||
|
A: 如果你当前弹窗能正常显示 `发现新版本`,就不需要额外做特殊处理,按普通更新步骤走即可。如果弹窗一直只显示 `暂时无法检查更新`,建议手动用最新压缩包重新安装一次 `dist`,完成一次性升级。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ 每日使用 checklist
|
## ✅ 每日使用 checklist
|
||||||
|
|||||||
Binary file not shown.
5
scripts/build-output-path.mjs
Normal file
5
scripts/build-output-path.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function resolveExtensionBuildDir(projectRoot, _buildTarget) {
|
||||||
|
return path.join(projectRoot, "dist");
|
||||||
|
}
|
||||||
@ -3,15 +3,13 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { build } from "tsup";
|
import { build } from "tsup";
|
||||||
import { createManifest } from "./manifest.mjs";
|
import { createManifest } from "./manifest.mjs";
|
||||||
|
import { resolveExtensionBuildDir } from "./build-output-path.mjs";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const projectRoot = path.resolve(__dirname, "..");
|
const projectRoot = path.resolve(__dirname, "..");
|
||||||
const buildTarget = process.env.BUILD_TARGET === "release" ? "release" : "development";
|
const buildTarget = process.env.BUILD_TARGET === "release" ? "release" : "development";
|
||||||
const distDir = path.join(
|
const distDir = resolveExtensionBuildDir(projectRoot, buildTarget);
|
||||||
projectRoot,
|
|
||||||
buildTarget === "release" ? "dist-release" : "dist"
|
|
||||||
);
|
|
||||||
|
|
||||||
await rm(distDir, { recursive: true, force: true });
|
await rm(distDir, { recursive: true, force: true });
|
||||||
await mkdir(path.join(distDir, "content"), { recursive: true });
|
await mkdir(path.join(distDir, "content"), { recursive: true });
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import yazl from "yazl";
|
|||||||
|
|
||||||
export async function createReleaseArchive({
|
export async function createReleaseArchive({
|
||||||
archivePath,
|
archivePath,
|
||||||
rootDirName = "star-chart-search-enhancer-internal",
|
rootDirName = "dist",
|
||||||
sourceDir
|
sourceDir
|
||||||
}) {
|
}) {
|
||||||
const zip = new yazl.ZipFile();
|
const zip = new yazl.ZipFile();
|
||||||
|
|||||||
@ -2,10 +2,11 @@ import { mkdir, rm } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createReleaseArchive } from "./package-release-archive.mjs";
|
import { createReleaseArchive } from "./package-release-archive.mjs";
|
||||||
|
import { resolveExtensionBuildDir } from "./build-output-path.mjs";
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const projectRoot = path.resolve(__dirname, "..");
|
const projectRoot = path.resolve(__dirname, "..");
|
||||||
const sourceDir = path.join(projectRoot, "dist-release");
|
const sourceDir = resolveExtensionBuildDir(projectRoot, "release");
|
||||||
const releaseDir = path.join(projectRoot, "release");
|
const releaseDir = path.join(projectRoot, "release");
|
||||||
const archivePath = path.join(
|
const archivePath = path.join(
|
||||||
releaseDir,
|
releaseDir,
|
||||||
|
|||||||
@ -4,6 +4,183 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Star Chart Search Enhancer</title>
|
<title>Star Chart Search Enhancer</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--popup-bg: #f3f4f6;
|
||||||
|
--popup-card: #ffffff;
|
||||||
|
--popup-text: #111827;
|
||||||
|
--popup-subtle: #6b7280;
|
||||||
|
--popup-border: #d1d5db;
|
||||||
|
--popup-accent: #8f1f4b;
|
||||||
|
--popup-accent-strong: #74183b;
|
||||||
|
--popup-success: #065f46;
|
||||||
|
--popup-warning: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 380px;
|
||||||
|
min-height: 560px;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--popup-bg);
|
||||||
|
color: var(--popup-text);
|
||||||
|
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header {
|
||||||
|
padding: 6px 2px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-eyebrow {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--popup-subtle);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.08;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-card {
|
||||||
|
background: var(--popup-card);
|
||||||
|
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-card-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--popup-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-status {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-status--accent {
|
||||||
|
color: var(--popup-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-user {
|
||||||
|
margin: 0 0 2px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-copy,
|
||||||
|
.popup-error,
|
||||||
|
.popup-warning,
|
||||||
|
.popup-success {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--popup-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-error {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-warning {
|
||||||
|
color: var(--popup-warning);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-success {
|
||||||
|
color: var(--popup-success);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-notes {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--popup-text);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-notes li + li {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-button {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--popup-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-button--primary {
|
||||||
|
border-color: var(--popup-accent);
|
||||||
|
background: var(--popup-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-button--primary:hover {
|
||||||
|
background: var(--popup-accent-strong);
|
||||||
|
border-color: var(--popup-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-button--secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--popup-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-button--secondary:hover,
|
||||||
|
.popup-button--ghost:hover {
|
||||||
|
background: rgba(17, 24, 39, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-button--ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--popup-subtle);
|
||||||
|
border-color: transparent;
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
|
|||||||
@ -104,7 +104,7 @@ async function renderCurrentAuthState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderLoggedIn(root, response.value);
|
renderLoggedIn(root, response.value);
|
||||||
void runUpdateCheck(root, sendMessage, updateOptions);
|
await runUpdateCheck(root, sendMessage, updateOptions);
|
||||||
root
|
root
|
||||||
.querySelector('[data-popup-sign-out="button"]')
|
.querySelector('[data-popup-sign-out="button"]')
|
||||||
?.addEventListener("click", () => {
|
?.addEventListener("click", () => {
|
||||||
@ -195,9 +195,10 @@ async function runUpdateCheck(
|
|||||||
status: "available"
|
status: "available"
|
||||||
});
|
});
|
||||||
bindUpdateDownloadButtons(root, sendMessage, manifest);
|
bindUpdateDownloadButtons(root, sendMessage, manifest);
|
||||||
} catch {
|
} catch (error) {
|
||||||
renderUpdateStatus(root, {
|
renderUpdateStatus(root, {
|
||||||
currentVersion: options.currentVersion,
|
currentVersion: options.currentVersion,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
status: "error"
|
status: "error"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,18 @@ import type { UpdateManifest } from "../shared/update-check";
|
|||||||
|
|
||||||
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
|
export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<section data-popup-state="logged-out">
|
<section class="popup-shell" data-popup-shell="root" data-popup-state="logged-out">
|
||||||
|
<header class="popup-header" data-popup-header="root">
|
||||||
|
<p class="popup-eyebrow">内部工具</p>
|
||||||
<h1>Star Chart Search Enhancer</h1>
|
<h1>Star Chart Search Enhancer</h1>
|
||||||
<p>登录后才能使用星图增强功能</p>
|
</header>
|
||||||
${error ? `<p data-popup-error="true">${error}</p>` : ""}
|
<section class="popup-card popup-card--account" data-popup-account="card">
|
||||||
<button type="button" data-popup-sign-in="button">登录 Logto</button>
|
<div class="popup-card-title">登录状态</div>
|
||||||
|
<p class="popup-status">未登录</p>
|
||||||
|
<p class="popup-copy">登录后才能使用星图增强功能</p>
|
||||||
|
${error ? `<p class="popup-error" data-popup-error="true">${escapeHtml(error)}</p>` : ""}
|
||||||
|
<button type="button" class="popup-button popup-button--primary" data-popup-sign-in="button">登录 Logto</button>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -19,16 +26,24 @@ export function renderLoggedIn(
|
|||||||
const userInfo = authState.userInfo;
|
const userInfo = authState.userInfo;
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<section data-popup-state="logged-in">
|
<section class="popup-shell" data-popup-shell="root" data-popup-state="logged-in">
|
||||||
|
<header class="popup-header" data-popup-header="root">
|
||||||
|
<p class="popup-eyebrow">内部工具</p>
|
||||||
<h1>Star Chart Search Enhancer</h1>
|
<h1>Star Chart Search Enhancer</h1>
|
||||||
<p>已登录</p>
|
</header>
|
||||||
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p>
|
<section class="popup-card popup-card--account" data-popup-account="card">
|
||||||
<p>${userInfo?.email ?? ""}</p>
|
<div class="popup-card-title">登录状态</div>
|
||||||
<section data-popup-update="root">
|
<p class="popup-status">已登录</p>
|
||||||
<h2>版本更新</h2>
|
<p class="popup-user">${escapeHtml(userInfo?.name ?? userInfo?.username ?? "未知用户")}</p>
|
||||||
<p data-popup-update-status="text">正在检查更新...</p>
|
<p class="popup-copy">${escapeHtml(userInfo?.email ?? "")}</p>
|
||||||
</section>
|
</section>
|
||||||
<button type="button" data-popup-sign-out="button">退出登录</button>
|
<section class="popup-card popup-card--update" data-popup-update="card" data-popup-update-root="root">
|
||||||
|
<div class="popup-card-title">版本更新</div>
|
||||||
|
<p data-popup-update-status="text" class="popup-status">正在检查更新...</p>
|
||||||
|
</section>
|
||||||
|
<footer class="popup-footer">
|
||||||
|
<button type="button" class="popup-button popup-button--ghost" data-popup-sign-out="button">退出登录</button>
|
||||||
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -38,50 +53,54 @@ export function renderUpdateStatus(
|
|||||||
options: {
|
options: {
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
manifest?: UpdateManifest;
|
manifest?: UpdateManifest;
|
||||||
|
message?: string | null;
|
||||||
status: "checking" | "error" | "latest" | "available";
|
status: "checking" | "error" | "latest" | "available";
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
const container = root.querySelector('[data-popup-update="root"]');
|
const container = root.querySelector('[data-popup-update-root="root"]');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.status === "checking") {
|
if (options.status === "checking") {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<h2>版本更新</h2>
|
<div class="popup-card-title">版本更新</div>
|
||||||
<p data-popup-update-status="text">当前版本:${options.currentVersion}</p>
|
<p data-popup-update-status="text" class="popup-status">当前版本:${options.currentVersion}</p>
|
||||||
<p>正在检查更新...</p>
|
<p class="popup-copy">正在检查更新...</p>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.status === "error") {
|
if (options.status === "error") {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<h2>版本更新</h2>
|
<div class="popup-card-title">版本更新</div>
|
||||||
<p data-popup-update-status="text">当前版本:${options.currentVersion}</p>
|
<p data-popup-update-status="text" class="popup-status">当前版本:${options.currentVersion}</p>
|
||||||
<p>暂时无法检查更新</p>
|
<p class="popup-warning">暂时无法检查更新</p>
|
||||||
<p>如果需要新版,请联系维护同事获取更新包。</p>
|
${options.message ? `<p class="popup-error">${escapeHtml(options.message)}</p>` : ""}
|
||||||
|
<p class="popup-copy">如果需要新版,请联系维护同事获取更新包。</p>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.status === "latest" || !options.manifest) {
|
if (options.status === "latest" || !options.manifest) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<h2>版本更新</h2>
|
<div class="popup-card-title">版本更新</div>
|
||||||
<p data-popup-update-status="text">当前版本:${options.currentVersion}</p>
|
<p data-popup-update-status="text" class="popup-status">当前版本:${options.currentVersion}</p>
|
||||||
<p>当前已是最新版本</p>
|
<p class="popup-success">当前已是最新版本</p>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<h2>版本更新</h2>
|
<div class="popup-card-title">版本更新</div>
|
||||||
<p data-popup-update-status="text">当前版本:${options.currentVersion}</p>
|
<p data-popup-update-status="text" class="popup-status">当前版本:${options.currentVersion}</p>
|
||||||
<p>发现新版本:${options.manifest.latestVersion}</p>
|
<p class="popup-status popup-status--accent">发现新版本:${options.manifest.latestVersion}</p>
|
||||||
${renderReleaseNotes(options.manifest.releaseNotes)}
|
${renderReleaseNotes(options.manifest.releaseNotes)}
|
||||||
<button type="button" data-popup-download-update="button">下载更新包</button>
|
<div class="popup-actions">
|
||||||
<button type="button" data-popup-download-guide="button">下载使用说明</button>
|
<button type="button" class="popup-button popup-button--primary" data-popup-download-update="button">下载更新包</button>
|
||||||
<p data-popup-update-download-status="text">下载后请解压新版 zip,并在 chrome://extensions 里重新加载插件。</p>
|
<button type="button" class="popup-button popup-button--secondary" data-popup-download-guide="button">下载使用说明</button>
|
||||||
|
</div>
|
||||||
|
<p data-popup-update-download-status="text" class="popup-copy">下载后请解压新版 zip,并在 chrome://extensions 里重新加载插件。</p>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,7 +122,7 @@ function renderReleaseNotes(releaseNotes: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<ul>
|
<ul class="popup-notes">
|
||||||
${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
|
${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
|
||||||
</ul>
|
</ul>
|
||||||
`;
|
`;
|
||||||
@ -122,15 +141,16 @@ export function renderDevPanel(
|
|||||||
authState: AuthStateValue
|
authState: AuthStateValue
|
||||||
): void {
|
): void {
|
||||||
const panel = root.ownerDocument.createElement("section");
|
const panel = root.ownerDocument.createElement("section");
|
||||||
|
panel.className = "popup-card popup-card--dev";
|
||||||
panel.dataset.popupDevPanel = "root";
|
panel.dataset.popupDevPanel = "root";
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<h2>dev auth panel</h2>
|
<div class="popup-card-title">dev auth panel</div>
|
||||||
<p>resource: ${authState.resource ?? ""}</p>
|
<p class="popup-copy">resource: ${escapeHtml(authState.resource ?? "")}</p>
|
||||||
<p>scopes: ${(authState.scopes ?? []).join(", ")}</p>
|
<p class="popup-copy">scopes: ${escapeHtml((authState.scopes ?? []).join(", "))}</p>
|
||||||
<p>token: ${authState.tokenAvailable ? "available" : "missing"}</p>
|
<p class="popup-copy">token: ${authState.tokenAvailable ? "available" : "missing"}</p>
|
||||||
<p>expires: ${authState.accessTokenExpiresAt ?? "unknown"}</p>
|
<p class="popup-copy">expires: ${escapeHtml(String(authState.accessTokenExpiresAt ?? "unknown"))}</p>
|
||||||
<p>error: ${authState.lastError ?? ""}</p>
|
<p class="popup-copy">error: ${escapeHtml(authState.lastError ?? "")}</p>
|
||||||
<button type="button" data-popup-test-protected-api="button">测试受保护接口</button>
|
<button type="button" class="popup-button popup-button--secondary" data-popup-test-protected-api="button">测试受保护接口</button>
|
||||||
<pre data-popup-protected-api-result="output"></pre>
|
<pre data-popup-protected-api-result="output"></pre>
|
||||||
`;
|
`;
|
||||||
root.appendChild(panel);
|
root.appendChild(panel);
|
||||||
|
|||||||
18
tests/build-output-path.test.ts
Normal file
18
tests/build-output-path.test.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { resolveExtensionBuildDir } from "../scripts/build-output-path.mjs";
|
||||||
|
|
||||||
|
describe("build-output-path", () => {
|
||||||
|
test("uses dist for release builds", () => {
|
||||||
|
expect(resolveExtensionBuildDir("/repo", "release")).toBe(
|
||||||
|
path.join("/repo", "dist")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses dist for development builds", () => {
|
||||||
|
expect(resolveExtensionBuildDir("/repo", "development")).toBe(
|
||||||
|
path.join("/repo", "dist")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -6,7 +6,7 @@ import { describe, expect, test } from "vitest";
|
|||||||
import { createReleaseArchive } from "../scripts/package-release-archive.mjs";
|
import { createReleaseArchive } from "../scripts/package-release-archive.mjs";
|
||||||
|
|
||||||
describe("package-release-archive", () => {
|
describe("package-release-archive", () => {
|
||||||
test("creates a zip archive with a top-level release folder", async () => {
|
test("creates a zip archive with a top-level dist folder", async () => {
|
||||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "release-archive-"));
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "release-archive-"));
|
||||||
const sourceDir = path.join(tempDir, "source");
|
const sourceDir = path.join(tempDir, "source");
|
||||||
const archivePath = path.join(tempDir, "archive.zip");
|
const archivePath = path.join(tempDir, "archive.zip");
|
||||||
@ -21,8 +21,6 @@ describe("package-release-archive", () => {
|
|||||||
|
|
||||||
const archive = await readFile(archivePath);
|
const archive = await readFile(archivePath);
|
||||||
expect(archive.byteLength).toBeGreaterThan(0);
|
expect(archive.byteLength).toBeGreaterThan(0);
|
||||||
expect(archive.toString("utf8")).toContain(
|
expect(archive.toString("utf8")).toContain("dist/hello.txt");
|
||||||
"star-chart-search-enhancer-internal/hello.txt"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,6 +3,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
|||||||
|
|
||||||
import { bootPopup } from "../src/popup/index";
|
import { bootPopup } from "../src/popup/index";
|
||||||
|
|
||||||
|
function flushTasks(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
describe("popup-entry", () => {
|
describe("popup-entry", () => {
|
||||||
let dom: JSDOM;
|
let dom: JSDOM;
|
||||||
|
|
||||||
@ -22,6 +26,12 @@ describe("popup-entry", () => {
|
|||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-popup-shell="root"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-popup-account="card"]')
|
||||||
|
).not.toBeNull();
|
||||||
expect(dom.window.document.querySelector("button")?.textContent).toContain(
|
expect(dom.window.document.querySelector("button")?.textContent).toContain(
|
||||||
"登录"
|
"登录"
|
||||||
);
|
);
|
||||||
@ -47,6 +57,12 @@ describe("popup-entry", () => {
|
|||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-popup-header="root"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-popup-account="card"]')
|
||||||
|
).not.toBeNull();
|
||||||
expect(dom.window.document.body.textContent).toContain("resource");
|
expect(dom.window.document.body.textContent).toContain("resource");
|
||||||
expect(dom.window.document.body.textContent).toContain("token");
|
expect(dom.window.document.body.textContent).toContain("token");
|
||||||
});
|
});
|
||||||
@ -76,12 +92,16 @@ describe("popup-entry", () => {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
await Promise.resolve();
|
await flushTasks();
|
||||||
|
await flushTasks();
|
||||||
|
|
||||||
expect(fetchUpdateManifest).toHaveBeenCalledTimes(1);
|
expect(fetchUpdateManifest).toHaveBeenCalledTimes(1);
|
||||||
expect(dom.window.document.body.textContent).toContain("当前版本:0.2.0421.2");
|
expect(dom.window.document.body.textContent).toContain("当前版本:0.2.0421.2");
|
||||||
expect(dom.window.document.body.textContent).toContain("发现新版本:0.2.0421.3");
|
expect(dom.window.document.body.textContent).toContain("发现新版本:0.2.0421.3");
|
||||||
expect(dom.window.document.body.textContent).toContain("支持检查更新");
|
expect(dom.window.document.body.textContent).toContain("支持检查更新");
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-popup-update="card"]')
|
||||||
|
).not.toBeNull();
|
||||||
expect(
|
expect(
|
||||||
dom.window.document.querySelector('[data-popup-download-update="button"]')
|
dom.window.document.querySelector('[data-popup-download-update="button"]')
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
@ -118,7 +138,8 @@ describe("popup-entry", () => {
|
|||||||
})),
|
})),
|
||||||
sendMessage
|
sendMessage
|
||||||
});
|
});
|
||||||
await Promise.resolve();
|
await flushTasks();
|
||||||
|
await flushTasks();
|
||||||
|
|
||||||
(
|
(
|
||||||
dom.window.document.querySelector(
|
dom.window.document.querySelector(
|
||||||
@ -245,6 +266,63 @@ describe("popup-entry", () => {
|
|||||||
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:sign-out" });
|
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:sign-out" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows the latest state without update actions", async () => {
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
currentVersion: "0.0525.5",
|
||||||
|
document: dom.window.document,
|
||||||
|
fetchUpdateManifest: vi.fn(async () => ({
|
||||||
|
guideUrl: "https://cos.example.com/guide.pdf",
|
||||||
|
latestVersion: "0.0525.5",
|
||||||
|
minSupportedVersion: "0.0525.5",
|
||||||
|
publishedAt: "2026-05-19",
|
||||||
|
releaseNotes: ["支持检查更新"],
|
||||||
|
zipUrl: "https://cos.example.com/plugin.zip"
|
||||||
|
})),
|
||||||
|
sendMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
userInfo: { name: "Dev" }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
await flushTasks();
|
||||||
|
await flushTasks();
|
||||||
|
|
||||||
|
expect(dom.window.document.body.textContent).toContain("当前已是最新版本");
|
||||||
|
expect(
|
||||||
|
dom.window.document.querySelector('[data-popup-download-update="button"]')
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows a readable error state when the manifest fetch fails", async () => {
|
||||||
|
dom.window.document.body.innerHTML = "<main id='app'></main>";
|
||||||
|
|
||||||
|
await bootPopup({
|
||||||
|
currentVersion: "0.0525.5",
|
||||||
|
document: dom.window.document,
|
||||||
|
fetchUpdateManifest: vi.fn(async () => {
|
||||||
|
throw new Error("network down");
|
||||||
|
}),
|
||||||
|
sendMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:state",
|
||||||
|
value: {
|
||||||
|
isAuthenticated: true,
|
||||||
|
userInfo: { name: "Dev" }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
await flushTasks();
|
||||||
|
await flushTasks();
|
||||||
|
|
||||||
|
expect(dom.window.document.body.textContent).toContain("暂时无法检查更新");
|
||||||
|
expect(dom.window.document.body.textContent).toContain("network down");
|
||||||
|
});
|
||||||
|
|
||||||
test("shows the auth error when sign-in fails", async () => {
|
test("shows the auth error when sign-in fails", async () => {
|
||||||
const sendMessage = vi
|
const sendMessage = vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user