mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-02 02:13:52 +00:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # web/src/api/client.ts # web/src/bootstrap.ts
This commit is contained in:
commit
681bdbffbe
32 changed files with 1082 additions and 108 deletions
48
.github/ISSUE_TEMPLATE/reward-task.yml
vendored
Normal file
48
.github/ISSUE_TEMPLATE/reward-task.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
name: 💰 Reward Task
|
||||
description: Task issue with Reward
|
||||
title: '[Reward] '
|
||||
labels:
|
||||
- reward
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Task description
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: currency
|
||||
attributes:
|
||||
label: Reward currency
|
||||
options:
|
||||
- 'USD $'
|
||||
- 'CAD C$'
|
||||
- 'AUD A$'
|
||||
- 'GBP £'
|
||||
- 'EUR €'
|
||||
- 'CNY ¥'
|
||||
- 'HKD HK$'
|
||||
- 'TWD NT$'
|
||||
- 'SGD S$'
|
||||
- 'KRW ₩'
|
||||
- 'JPY ¥'
|
||||
- 'INR ₹'
|
||||
- 'UAH ₴'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: amount
|
||||
attributes:
|
||||
label: Reward amount
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: payer
|
||||
attributes:
|
||||
label: Reward payer
|
||||
description: GitHub username of the payer (optional, defaults to issue creator)
|
||||
validations:
|
||||
required: false
|
||||
66
.github/scripts/count-reward.ts
vendored
Normal file
66
.github/scripts/count-reward.ts
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { $, YAML } from "npm:zx";
|
||||
|
||||
import { Reward } from "./type.ts";
|
||||
|
||||
$.verbose = true;
|
||||
|
||||
const rawTags =
|
||||
await $`git tag --list "reward-*" --format="%(refname:short) %(creatordate:short)"`;
|
||||
|
||||
const lastMonth = new Date();
|
||||
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
||||
const lastMonthStr = lastMonth.toJSON().slice(0, 7);
|
||||
|
||||
const rewardTags = rawTags.stdout
|
||||
.split("\n")
|
||||
.filter((line) => line.split(/\s+/)[1] >= lastMonthStr)
|
||||
.map((line) => line.split(/\s+/)[0]);
|
||||
|
||||
let rawYAML = "";
|
||||
|
||||
for (const tag of rewardTags)
|
||||
rawYAML += (await $`git tag -l --format="%(contents)" ${tag}`) + "\n";
|
||||
|
||||
if (!rawYAML.trim()) {
|
||||
console.warn("No reward data is found for the last month.");
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const rewards = YAML.parse(rawYAML) as Reward[];
|
||||
|
||||
const groupedRewards = Object.groupBy(rewards, ({ payee }) => payee);
|
||||
|
||||
const summaryList = Object.entries(groupedRewards).map(([payee, rewards]) => {
|
||||
const reward = rewards!.reduce(
|
||||
(acc, { currency, reward }) => {
|
||||
acc[currency] ??= 0;
|
||||
acc[currency] += reward;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
return {
|
||||
payee,
|
||||
reward,
|
||||
accounts: rewards!.map(({ payee: _, ...account }) => account),
|
||||
};
|
||||
});
|
||||
|
||||
const summaryText = YAML.stringify(summaryList);
|
||||
|
||||
console.log(summaryText);
|
||||
|
||||
const tagName = `statistic-${new Date().toJSON().slice(0, 7)}`;
|
||||
|
||||
await $`git config user.name "github-actions[bot]"`;
|
||||
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`;
|
||||
|
||||
await $`git tag -a ${tagName} $(git rev-parse HEAD) -m ${summaryText}`;
|
||||
await $`git push origin --tags --no-verify`;
|
||||
|
||||
await $`git config unset user.name`;
|
||||
await $`git config unset user.email`;
|
||||
|
||||
await $`gh release create ${tagName} --notes ${summaryText}`;
|
||||
3
.github/scripts/deno.json
vendored
Normal file
3
.github/scripts/deno.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"nodeModulesDir": "none"
|
||||
}
|
||||
125
.github/scripts/share-reward.ts
vendored
Normal file
125
.github/scripts/share-reward.ts
vendored
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import "npm:array-unique-proposal";
|
||||
|
||||
import { components } from "npm:@octokit/openapi-types";
|
||||
import { $, argv, YAML } from "npm:zx";
|
||||
|
||||
import { Reward } from "./type.ts";
|
||||
|
||||
$.verbose = true;
|
||||
|
||||
const [
|
||||
repositoryOwner,
|
||||
repositoryName,
|
||||
issueNumber,
|
||||
payer, // GitHub username of the payer (provided by workflow, defaults to issue creator)
|
||||
currency,
|
||||
reward,
|
||||
] = argv._;
|
||||
|
||||
interface PRMeta {
|
||||
author: components["schemas"]["simple-user"];
|
||||
assignees: components["schemas"]["simple-user"][];
|
||||
}
|
||||
|
||||
const graphqlQuery = `
|
||||
query($owner: String!, $name: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issue(number: $number) {
|
||||
closedByPullRequestsReferences(first: 10) {
|
||||
nodes {
|
||||
url
|
||||
merged
|
||||
mergeCommit {
|
||||
oid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const PR_DATA = await $`gh api graphql \
|
||||
-f query=${graphqlQuery} \
|
||||
-f owner=${repositoryOwner} \
|
||||
-f name=${repositoryName} \
|
||||
-F number=${issueNumber} \
|
||||
--jq '.data.repository.issue.closedByPullRequestsReferences.nodes[] | select(.merged == true) | {url: .url, mergeCommitSha: .mergeCommit.oid}' | head -n 1`;
|
||||
|
||||
const prData = PR_DATA.text().trim();
|
||||
|
||||
if (!prData)
|
||||
throw new ReferenceError("No merged PR is found for the given issue number.");
|
||||
|
||||
const { url: PR_URL, mergeCommitSha } = JSON.parse(prData);
|
||||
|
||||
if (!PR_URL || !mergeCommitSha)
|
||||
throw new Error("Missing required fields in PR data");
|
||||
|
||||
console.table({ PR_URL, mergeCommitSha });
|
||||
|
||||
const { author, assignees }: PRMeta = await (
|
||||
await $`gh pr view ${PR_URL} --json author,assignees`
|
||||
).json();
|
||||
|
||||
function isBotUser(login: string) {
|
||||
const lowerLogin = login.toLowerCase();
|
||||
return (
|
||||
lowerLogin.includes("copilot") ||
|
||||
lowerLogin.includes("[bot]") ||
|
||||
lowerLogin === "github-actions[bot]" ||
|
||||
lowerLogin.endsWith("[bot]")
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out Bot users from the list
|
||||
const allUsers = [
|
||||
author.login,
|
||||
...assignees.map(({ login }) => login),
|
||||
].uniqueBy();
|
||||
|
||||
const users = allUsers.filter((login) => !isBotUser(login));
|
||||
|
||||
console.log(`All users: ${allUsers.join(", ")}`);
|
||||
console.log(`Filtered users (excluding bots): ${users.join(", ")}`);
|
||||
|
||||
if (!users[0])
|
||||
throw new ReferenceError(
|
||||
"No real users found (all users are bots). Skipping reward distribution.",
|
||||
);
|
||||
|
||||
const rewardNumber = parseFloat(reward);
|
||||
|
||||
if (isNaN(rewardNumber) || rewardNumber <= 0)
|
||||
throw new RangeError(
|
||||
`Reward amount is not a valid number, can not proceed with reward distribution. Received reward value: ${reward}`,
|
||||
);
|
||||
|
||||
const averageReward = (rewardNumber / users.length).toFixed(2);
|
||||
|
||||
const list: Reward[] = users.map((login) => ({
|
||||
issue: `#${issueNumber}`,
|
||||
payer: `@${payer}`,
|
||||
payee: `@${login}`,
|
||||
currency,
|
||||
reward: parseFloat(averageReward),
|
||||
}));
|
||||
const listText = YAML.stringify(list);
|
||||
|
||||
console.log(listText);
|
||||
|
||||
await $`git config user.name "github-actions[bot]"`;
|
||||
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`;
|
||||
|
||||
await $`git tag -a "reward-${issueNumber}" ${mergeCommitSha} -m ${listText}`;
|
||||
await $`git push origin --tags --no-verify`;
|
||||
|
||||
await $`git config unset user.name`;
|
||||
await $`git config unset user.email`;
|
||||
|
||||
const commentBody = `## Reward data
|
||||
|
||||
\`\`\`yml
|
||||
${listText}
|
||||
\`\`\`
|
||||
`;
|
||||
await $`gh issue comment ${issueNumber} --body ${commentBody}`;
|
||||
7
.github/scripts/type.ts
vendored
Normal file
7
.github/scripts/type.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export interface Reward {
|
||||
issue: string;
|
||||
payer: string;
|
||||
payee: string;
|
||||
currency: string;
|
||||
reward: number;
|
||||
}
|
||||
46
.github/workflows/claim-issue-reward.yml
vendored
Normal file
46
.github/workflows/claim-issue-reward.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: Claim Issue Reward
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- closed
|
||||
|
||||
concurrency:
|
||||
group: claim-issue-reward-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
claim-issue-reward:
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.issue.labels.*.name, 'reward')
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Get Issue details
|
||||
id: parse_issue
|
||||
uses: stefanbuck/github-issue-parser@10dcc54158ba4c137713d9d69d70a2da63b6bda3 # v3.2.3
|
||||
with:
|
||||
template-path: ".github/ISSUE_TEMPLATE/reward-task.yml"
|
||||
|
||||
- name: Calculate & Save Reward
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
deno --allow-run --allow-sys --allow-env --allow-read --allow-net=api.github.com \
|
||||
.github/scripts/share-reward.ts \
|
||||
"${{ github.repository_owner }}" \
|
||||
"${{ github.event.repository.name }}" \
|
||||
"${{ github.event.issue.number }}" \
|
||||
"${{ steps.parse_issue.outputs.issueparser_payer || github.event.issue.user.login }}" \
|
||||
"${{ steps.parse_issue.outputs.issueparser_currency }}" \
|
||||
"${{ steps.parse_issue.outputs.issueparser_amount }}"
|
||||
43
.github/workflows/statistic-member-reward.yml
vendored
Normal file
43
.github/workflows/statistic-member-reward.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: Statistic Member Reward
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 1 * *" # Run at 00:00 on the first day of every month
|
||||
|
||||
jobs:
|
||||
statistic-member-reward:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Check for new commits since last statistic
|
||||
run: |
|
||||
last_tag=$(git describe --tags --abbrev=0 --match "statistic-*" || echo "")
|
||||
|
||||
if [ -z "$last_tag" ]; then
|
||||
echo "No previous statistic tags found."
|
||||
echo "NEW_COMMITS=true" >> $GITHUB_ENV
|
||||
else
|
||||
new_commits=$(git log $last_tag..HEAD --oneline)
|
||||
if [ -z "$new_commits" ]; then
|
||||
echo "No new commits since last statistic tag."
|
||||
echo "NEW_COMMITS=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "New commits found."
|
||||
echo "NEW_COMMITS=true" >> $GITHUB_ENV
|
||||
fi
|
||||
fi
|
||||
- uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
|
||||
if: env.NEW_COMMITS == 'true'
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Statistic rewards
|
||||
if: env.NEW_COMMITS == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: deno --allow-run --allow-sys --allow-env --allow-read --allow-net=api.github.com .github/scripts/count-reward.ts
|
||||
19
Makefile
19
Makefile
|
|
@ -71,10 +71,21 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
|
|||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)' >/dev/null; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ "$$backend_ready" -ne 1 ]; then \
|
||||
echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
if [ "$$backend_ready" -ne 1 ]; then \
|
||||
echo ""; \
|
||||
echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \
|
||||
echo ""; \
|
||||
echo "Common issues:"; \
|
||||
echo " 1. Maven dependency download failed (network timeout)"; \
|
||||
echo " -> Configure mirror in ~/.m2/settings.xml"; \
|
||||
echo " -> See: https://maven.aliyun.com/mvn/guide"; \
|
||||
echo " 2. Java version mismatch (requires Java 21+)"; \
|
||||
echo " -> Run: java -version"; \
|
||||
echo " 3. Port 8080 already in use"; \
|
||||
echo " -> Run: lsof -i :8080"; \
|
||||
echo ""; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Waiting for scanner on $(DEV_SCANNER_URL) ..."
|
||||
@scanner_ready=0; \
|
||||
for i in $$(seq 1 30); do \
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@
|
|||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<img src="https://xfyun-doc.xfyun.cn/lc-sp-skillhub-demo-1775551643410.gif" alt="SkillHub Demo" width="800" />
|
||||
</div>
|
||||
|
||||
SkillHub is a self-hosted platform that gives teams a private,
|
||||
governed place to share agent skills. Publish a skill package, push
|
||||
it to a namespace, and let others find it through search or
|
||||
|
|
@ -106,6 +110,8 @@ If deployment runs into problems, clear the existing runtime home and retry.
|
|||
make dev-all
|
||||
```
|
||||
|
||||
> **For developers in China**: If Maven dependency download times out, configure Aliyun mirror. See [Local Development Guide](https://iflytek.github.io/skillhub/quickstart.html#本地开发) for details.
|
||||
|
||||
Then open:
|
||||
|
||||
- Web UI: `http://localhost:3000`
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<img src="https://xfyun-doc.xfyun.cn/lc-sp-skillhub-demo-1775551643410.gif" alt="SkillHub Demo" width="800" />
|
||||
</div>
|
||||
|
||||
SkillHub 是一个自托管平台,为团队提供私有的、受治理的智能体技能共享空间。发布技能包,推送到命名空间,让其他人通过搜索发现或通过 CLI 安装。专为防火墙后的本地部署而构建,提供与公共注册中心相同的精致体验。
|
||||
|
||||
## 文档
|
||||
|
|
@ -126,6 +130,8 @@ make dev-backend # 仅后端
|
|||
make dev-web # 仅前端
|
||||
```
|
||||
|
||||
> **国内开发者**:如果 Maven 依赖下载超时,需配置阿里云镜像。详见 [本地开发指南](https://iflytek.github.io/skillhub/quickstart.html#本地开发)。
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -124,6 +124,18 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
|
|||
|
||||
> **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway.
|
||||
|
||||
## Q: Why can't administrators (admin) and regular users create namespaces?
|
||||
|
||||
A: Older versions of SkillHub do not support creating namespaces, as this feature was introduced in later updates. Please upgrade your SkillHub instance to the latest version (`latest`).
|
||||
Upgrade command example:
|
||||
```bash
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version latest
|
||||
```
|
||||
|
||||
## Q: How do I search for or operate on a skill package within a specific namespace?
|
||||
|
||||
A: When using the OpenClaw CLI, you can specify the namespace using the `<namespace>--<skill-name>` format for operations like search or installation. If you encounter issues finding it on the web interface, you can also manage it by exporting the skill package and importing it into your target namespace.
|
||||
|
||||
## Q: What should I do if I encounter issues?
|
||||
|
||||
A: You can get help through the following channels:
|
||||
|
|
@ -131,3 +143,74 @@ A: You can get help through the following channels:
|
|||
- **GitHub Issues**: https://github.com/iflytek/skillhub/issues
|
||||
- **Documentation**: Refer to the project README.md
|
||||
- **Community Discussions**: https://github.com/iflytek/skillhub/discussions
|
||||
|
||||
## Q: What should I do if local development fails to start?
|
||||
|
||||
A: When `make dev-all` fails to start the backend, detailed error messages will be displayed. Common issues:
|
||||
|
||||
### 1. Maven dependency download failed (network timeout)
|
||||
|
||||
**Symptoms**: Backend logs show `Could not transfer artifact` or connection timeout
|
||||
|
||||
**Solution**: Configure Aliyun mirror
|
||||
|
||||
```bash
|
||||
# Copy the project's built-in mirror configuration to user directory
|
||||
mkdir -p ~/.m2
|
||||
cp server/.mvn/settings.xml ~/.m2/settings.xml
|
||||
```
|
||||
|
||||
Or manually create `~/.m2/settings.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<settings>
|
||||
<mirrors>
|
||||
<mirror>
|
||||
<id>aliyun</id>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<mirrorOf>central</mirrorOf>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
</settings>
|
||||
```
|
||||
|
||||
Reference: [Aliyun Maven Mirror Configuration Guide](https://maven.aliyun.com/mvn/guide)
|
||||
|
||||
### 2. Java version mismatch
|
||||
|
||||
**Symptoms**: `Unsupported class file major version` or `java.lang.NoSuchMethodError`
|
||||
|
||||
**Solution**: Install Java 21+
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install openjdk@21
|
||||
|
||||
# Verify version
|
||||
java -version
|
||||
```
|
||||
|
||||
### 3. Port already in use
|
||||
|
||||
**Symptoms**: `Port 8080 already in use`
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Find the process using the port
|
||||
lsof -i :8080
|
||||
|
||||
# Terminate the process
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### 4. View detailed logs
|
||||
|
||||
If the above solutions don't help, check the backend logs:
|
||||
|
||||
```bash
|
||||
make dev-logs SERVICE=backend
|
||||
# Or view directly
|
||||
cat .dev/server.log
|
||||
```
|
||||
|
|
|
|||
|
|
@ -64,6 +64,39 @@ cd skillhub
|
|||
make dev-all
|
||||
```
|
||||
|
||||
### Notes for Developers in China
|
||||
|
||||
If `make dev-all` fails to start the backend, common causes include:
|
||||
|
||||
1. **Maven dependency download timeout**
|
||||
|
||||
The project includes a built-in Aliyun mirror configuration (`server/.mvn/settings.xml`), but Maven does not automatically read project-level settings. You need to configure it manually:
|
||||
|
||||
```bash
|
||||
# Option 1: Copy to user directory (recommended)
|
||||
mkdir -p ~/.m2
|
||||
cp server/.mvn/settings.xml ~/.m2/settings.xml
|
||||
|
||||
# Option 2: Specify on each build
|
||||
cd server && ./mvnw -s .mvn/settings.xml package
|
||||
```
|
||||
|
||||
2. **Java version mismatch**
|
||||
|
||||
SkillHub requires Java 21+:
|
||||
```bash
|
||||
java -version
|
||||
```
|
||||
|
||||
3. **Port conflict**
|
||||
|
||||
Check if port 8080 is in use:
|
||||
```bash
|
||||
lsof -i :8080
|
||||
```
|
||||
|
||||
For detailed troubleshooting steps, see [FAQ](faq.md#local-development-startup-failure).
|
||||
|
||||
## Logging In
|
||||
|
||||
### Option 1: Use the Built-in Admin Account
|
||||
|
|
|
|||
|
|
@ -124,6 +124,18 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
|
|||
|
||||
> **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。
|
||||
|
||||
## Q: 为什么管理员(admin)和普通用户都无法创建命名空间?
|
||||
|
||||
A: 较旧版本的 SkillHub 不支持创建命名空间。该功能是在后续版本迭代中添加的。请将您的 SkillHub 升级到最新版本(latest)。
|
||||
升级命令示例:
|
||||
```bash
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version latest
|
||||
```
|
||||
|
||||
## Q: 如何搜索或操作指定命名空间中的技能包(Skill)?
|
||||
|
||||
A: 使用 OpenClaw CLI 命令行工具时,可以通过 `<namespace>--<skill-name>` 的格式来指定命名空间进行操作(例如搜索、安装)。如果在网页端搜索遇到问题,也可以尝试通过先导出技能、再导入到目标命名空间的方式来完成跨空间操作。
|
||||
|
||||
## Q: 遇到问题怎么办?
|
||||
|
||||
A: 可以通过以下方式获取帮助:
|
||||
|
|
@ -131,3 +143,74 @@ A: 可以通过以下方式获取帮助:
|
|||
- **GitHub Issues**: https://github.com/iflytek/skillhub/issues
|
||||
- **文档**: 参考项目 README.md
|
||||
- **社区讨论**: https://github.com/iflytek/skillhub/discussions
|
||||
|
||||
## Q: 本地开发启动失败怎么办?
|
||||
|
||||
A: `make dev-all` 后端启动失败时,会显示详细的错误提示。常见问题:
|
||||
|
||||
### 1. Maven 依赖下载失败(网络超时)
|
||||
|
||||
**症状**:后端日志显示 `Could not transfer artifact` 或连接超时
|
||||
|
||||
**解决方案**:配置阿里云镜像
|
||||
|
||||
```bash
|
||||
# 复制项目内置的镜像配置到用户目录
|
||||
mkdir -p ~/.m2
|
||||
cp server/.mvn/settings.xml ~/.m2/settings.xml
|
||||
```
|
||||
|
||||
或手动创建 `~/.m2/settings.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<settings>
|
||||
<mirrors>
|
||||
<mirror>
|
||||
<id>aliyun</id>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<mirrorOf>central</mirrorOf>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
</settings>
|
||||
```
|
||||
|
||||
参考:[阿里云 Maven 镜像配置指南](https://maven.aliyun.com/mvn/guide)
|
||||
|
||||
### 2. Java 版本不匹配
|
||||
|
||||
**症状**:`Unsupported class file major version` 或 `java.lang.NoSuchMethodError`
|
||||
|
||||
**解决方案**:安装 Java 21+
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install openjdk@21
|
||||
|
||||
# 验证版本
|
||||
java -version
|
||||
```
|
||||
|
||||
### 3. 端口被占用
|
||||
|
||||
**症状**:`Port 8080 already in use`
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 查看占用端口的进程
|
||||
lsof -i :8080
|
||||
|
||||
# 终止进程
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### 4. 查看详细日志
|
||||
|
||||
如果以上方案无法解决,查看后端日志:
|
||||
|
||||
```bash
|
||||
make dev-logs SERVICE=backend
|
||||
# 或直接查看
|
||||
cat .dev/server.log
|
||||
```
|
||||
|
|
|
|||
|
|
@ -64,6 +64,39 @@ cd skillhub
|
|||
make dev-all
|
||||
```
|
||||
|
||||
### 国内开发者注意事项
|
||||
|
||||
如果 `make dev-all` 后端启动失败,常见原因:
|
||||
|
||||
1. **Maven 依赖下载超时**
|
||||
|
||||
项目已内置阿里云镜像配置(`server/.mvn/settings.xml`),但 Maven 不会自动读取项目级配置。需要手动配置:
|
||||
|
||||
```bash
|
||||
# 方式一:复制到用户目录(推荐)
|
||||
mkdir -p ~/.m2
|
||||
cp server/.mvn/settings.xml ~/.m2/settings.xml
|
||||
|
||||
# 方式二:每次构建时指定
|
||||
cd server && ./mvnw -s .mvn/settings.xml package
|
||||
```
|
||||
|
||||
2. **Java 版本不匹配**
|
||||
|
||||
SkillHub 要求 Java 21+:
|
||||
```bash
|
||||
java -version
|
||||
```
|
||||
|
||||
3. **端口冲突**
|
||||
|
||||
检查 8080 端口是否被占用:
|
||||
```bash
|
||||
lsof -i :8080
|
||||
```
|
||||
|
||||
详细的错误排查步骤,请查看 [常见问题](faq.md#本地开发启动失败)。
|
||||
|
||||
## 登录系统
|
||||
|
||||
### 方式一:使用内置管理员账号
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ SKILLHUB_SCANNER_IMAGE_VALUE="${SKILLHUB_SCANNER_IMAGE:-}"
|
|||
POSTGRES_IMAGE_VALUE="${POSTGRES_IMAGE:-}"
|
||||
REDIS_IMAGE_VALUE="${REDIS_IMAGE:-}"
|
||||
DISABLE_SCANNER=false
|
||||
USE_ALIYUN=false
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
|
|
@ -36,6 +37,7 @@ while [ "$#" -gt 0 ]; do
|
|||
exit 1
|
||||
fi
|
||||
SKILLHUB_MIRROR_REGISTRY_VALUE="${SKILLHUB_ALIYUN_REGISTRY%/}/${SKILLHUB_ALIYUN_NAMESPACE}"
|
||||
USE_ALIYUN=true
|
||||
shift
|
||||
;;
|
||||
--mirror-registry)
|
||||
|
|
@ -114,7 +116,13 @@ EOF
|
|||
esac
|
||||
done
|
||||
|
||||
SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}"
|
||||
if [ "$USE_ALIYUN" = "true" ]; then
|
||||
SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://imageless.oss-cn-beijing.aliyuncs.com}"
|
||||
echo "Using Aliyun OSS for runtime files: $SKILLHUB_RAW_BASE"
|
||||
else
|
||||
SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}"
|
||||
echo "Using GitHub raw for runtime files: $SKILLHUB_RAW_BASE"
|
||||
fi
|
||||
COMPOSE_FILE="$SKILLHUB_HOME/compose.release.yml"
|
||||
ENV_EXAMPLE_FILE="$SKILLHUB_HOME/.env.release.example"
|
||||
ENV_FILE="$SKILLHUB_HOME/.env.release"
|
||||
|
|
|
|||
16
server/.mvn/settings.xml
Normal file
16
server/.mvn/settings.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0
|
||||
https://maven.apache.org/xsd/settings-1.2.0.xsd">
|
||||
|
||||
<mirrors>
|
||||
<mirror>
|
||||
<id>aliyun</id>
|
||||
<name>Aliyun Maven Mirror</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<mirrorOf>central</mirrorOf>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
|
||||
</settings>
|
||||
2
server/.mvn/wrapper/maven-wrapper.properties
vendored
2
server/.mvn/wrapper/maven-wrapper.properties
vendored
|
|
@ -1,3 +1,3 @@
|
|||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip
|
||||
distributionUrl=https://maven.aliyun.com/repository/public/org/apache/maven/apache-maven/3.9.13/apache-maven-3.9.13-bin.zip
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import com.iflytek.skillhub.controller.support.MultipartPackageExtractor;
|
|||
import com.iflytek.skillhub.controller.support.ZipPackageExtractor;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
|
|
@ -93,11 +94,11 @@ public class ClawHubCompatAppService {
|
|||
String hash,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug);
|
||||
SkillCoordinate coord = resolveQueryCoordinate(slug);
|
||||
|
||||
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
|
||||
context.namespace().getSlug(),
|
||||
context.skill().getSlug(),
|
||||
coord.namespace(),
|
||||
coord.slug(),
|
||||
"latest".equals(version) ? null : version,
|
||||
"latest".equals(version) ? "latest" : null,
|
||||
hash,
|
||||
|
|
@ -132,10 +133,22 @@ public class ClawHubCompatAppService {
|
|||
}
|
||||
|
||||
public String downloadLocationByQuery(String slug, String version) {
|
||||
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug);
|
||||
SkillCoordinate coord = resolveQueryCoordinate(slug);
|
||||
return "latest".equals(version)
|
||||
? "/api/v1/skills/" + context.namespace().getSlug() + "/" + context.skill().getSlug() + "/download"
|
||||
: "/api/v1/skills/" + context.namespace().getSlug() + "/" + context.skill().getSlug() + "/versions/" + version + "/download";
|
||||
? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download"
|
||||
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
|
||||
}
|
||||
|
||||
private SkillCoordinate resolveQueryCoordinate(String slug) {
|
||||
if (slug != null && slug.contains("--")) {
|
||||
return mapper.fromCanonical(slug);
|
||||
}
|
||||
try {
|
||||
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug);
|
||||
return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug());
|
||||
} catch (DomainNotFoundException ex) {
|
||||
return mapper.fromCanonical(slug);
|
||||
}
|
||||
}
|
||||
|
||||
public ClawHubSkillListResponse listSkills(int page,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import java.util.Set;
|
|||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
|
@ -105,6 +106,56 @@ class ClawHubCompatControllerTest {
|
|||
.andExpect(jsonPath("$.latestVersion.version").value("latest"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_query_with_canonical_slug_returns_correct_downloadUrl() throws Exception {
|
||||
when(skillQueryService.resolveVersion("team-ai", "my-skill", null, "latest", null, null, java.util.Map.of()))
|
||||
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
|
||||
1L, "team-ai", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/team-ai/my-skill/download"));
|
||||
|
||||
mockMvc.perform(get("/api/v1/resolve")
|
||||
.param("slug", "team-ai--my-skill")
|
||||
.param("version", "latest"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.match.version").value("latest"))
|
||||
.andExpect(jsonPath("$.latestVersion.version").value("latest"));
|
||||
|
||||
verify(skillQueryService).resolveVersion("team-ai", "my-skill", null, "latest", null, null, java.util.Map.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_query_with_legacy_slug_keeps_legacy_lookup_behavior() throws Exception {
|
||||
when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of()))
|
||||
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
|
||||
1L, "global", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
|
||||
|
||||
mockMvc.perform(get("/api/v1/resolve")
|
||||
.param("slug", "my-skill")
|
||||
.param("version", "latest"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.match.version").value("latest"))
|
||||
.andExpect(jsonPath("$.latestVersion.version").value("latest"));
|
||||
|
||||
verify(skillQueryService).resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void download_query_with_canonical_slug_redirects_to_namespace_skill_download() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/download")
|
||||
.param("slug", "team-ai--my-skill")
|
||||
.param("version", "latest"))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(header().string("Location", "/api/v1/skills/team-ai/my-skill/download"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void download_query_with_legacy_slug_keeps_legacy_lookup_behavior() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/download")
|
||||
.param("slug", "my-skill")
|
||||
.param("version", "latest"))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(header().string("Location", "/api/v1/skills/global/my-skill/download"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_with_version_returns_specified_version() throws Exception {
|
||||
when(skillQueryService.resolveVersion("global", "my-skill", "1.0.0", null, null, null, java.util.Map.of()))
|
||||
|
|
|
|||
|
|
@ -155,7 +155,12 @@ export class E2eTestDataBuilder {
|
|||
}
|
||||
|
||||
async createNamespace(base = 'e2e-team'): Promise<SeededNamespace> {
|
||||
const slug = `${base}-${this.suffix}`.slice(0, 64)
|
||||
const rawSlug = `${base}-${this.suffix}`
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
const slug = rawSlug.slice(0, 64)
|
||||
const displayName = `E2E ${slug}`
|
||||
|
||||
const created = await parseEnvelope<SeededNamespace>(
|
||||
|
|
|
|||
83
web/e2e/reviews-pagination.spec.ts
Normal file
83
web/e2e/reviews-pagination.spec.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
|
||||
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
interface ReviewPageData {
|
||||
total: number
|
||||
size: number
|
||||
}
|
||||
|
||||
async function fetchReviewPageMeta(page: Page, status: ReviewStatus): Promise<ReviewPageData> {
|
||||
const response = await page.request.get(`/api/web/reviews?status=${status}&page=0&size=20&sortDirection=DESC`)
|
||||
const body = await response.json() as ApiEnvelope<ReviewPageData>
|
||||
if (!response.ok() || body.code !== 0) {
|
||||
throw new Error(`Failed to query reviews for ${status}: status=${response.status()} code=${body.code} msg=${body.msg}`)
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
|
||||
test.describe('Review Management Pagination (Real API)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
await page.context().setExtraHTTPHeaders({
|
||||
'X-Mock-User-Id': 'local-admin',
|
||||
})
|
||||
})
|
||||
|
||||
test('matches pagination rendering with real review totals', async ({ page }) => {
|
||||
const statuses: ReviewStatus[] = ['PENDING', 'APPROVED', 'REJECTED']
|
||||
const metaByStatus = new Map<ReviewStatus, ReviewPageData>()
|
||||
|
||||
for (const status of statuses) {
|
||||
metaByStatus.set(status, await fetchReviewPageMeta(page, status))
|
||||
}
|
||||
|
||||
await page.goto('/dashboard/reviews')
|
||||
await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible()
|
||||
|
||||
const tabMeta: Record<ReviewStatus, { tabLabel: string; summaryPrefix: string }> = {
|
||||
PENDING: { tabLabel: 'Pending', summaryPrefix: 'Total' },
|
||||
APPROVED: { tabLabel: 'Approved', summaryPrefix: 'Total' },
|
||||
REJECTED: { tabLabel: 'Rejected', summaryPrefix: 'Total' },
|
||||
}
|
||||
|
||||
for (const status of statuses) {
|
||||
await page.getByRole('button', { name: tabMeta[status].tabLabel }).click()
|
||||
|
||||
const meta = metaByStatus.get(status)
|
||||
if (!meta) {
|
||||
throw new Error(`Missing metadata for ${status}`)
|
||||
}
|
||||
const totalPages = meta.size > 0 ? Math.ceil(meta.total / meta.size) : 0
|
||||
|
||||
if (meta.total === 0) {
|
||||
await expect(page.getByText('No review tasks')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Previous' })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Next' })).toHaveCount(0)
|
||||
continue
|
||||
}
|
||||
|
||||
const previousButton = page.getByRole('button', { name: 'Previous' }).first()
|
||||
const nextButton = page.getByRole('button', { name: 'Next' }).first()
|
||||
await expect(previousButton).toBeVisible()
|
||||
await expect(nextButton).toBeVisible()
|
||||
await expect(previousButton).toBeDisabled()
|
||||
|
||||
if (totalPages > 1) {
|
||||
await expect(page.getByText(new RegExp(`${tabMeta[status].summaryPrefix} ${meta.total} records, page 1`))).toBeVisible()
|
||||
await expect(nextButton).toBeEnabled()
|
||||
await nextButton.click()
|
||||
await expect(page.getByText(new RegExp(`${tabMeta[status].summaryPrefix} ${meta.total} records, page 2`))).toBeVisible()
|
||||
} else {
|
||||
await expect(nextButton).toBeDisabled()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -36,6 +36,7 @@ vi.mock('@/shared/lib/api-error', () => ({
|
|||
import {
|
||||
WEB_API_PREFIX,
|
||||
buildApiUrl,
|
||||
fetchText,
|
||||
getDirectAuthRuntimeConfig,
|
||||
getSessionBootstrapRuntimeConfig,
|
||||
} from './client'
|
||||
|
|
@ -45,6 +46,8 @@ beforeEach(() => {
|
|||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
if (originalWindow) {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
|
|
@ -79,6 +82,38 @@ describe('buildApiUrl', () => {
|
|||
const url = buildApiUrl('/api/v1/auth/me')
|
||||
expect(url).toBe('https://api.example.com/api/v1/auth/me')
|
||||
})
|
||||
|
||||
it('preserves base URL path prefixes', () => {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com/skill_hub' }
|
||||
const url = buildApiUrl('/api/v1/auth/me')
|
||||
expect(url).toBe('https://api.example.com/skill_hub/api/v1/auth/me')
|
||||
})
|
||||
|
||||
it('supports relative base URL path prefixes', () => {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: '/skill_hub' }
|
||||
const url = buildApiUrl('/api/v1/auth/me')
|
||||
expect(url).toBe('/skill_hub/api/v1/auth/me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchText', () => {
|
||||
it('applies base URL path prefixes for fetch requests', async () => {
|
||||
window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com/skill_hub' }
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await fetchText('/api/v1/auth/me')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.example.com/skill_hub/api/v1/auth/me',
|
||||
expect.objectContaining({
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDirectAuthRuntimeConfig', () => {
|
||||
|
|
|
|||
|
|
@ -267,6 +267,12 @@ export function buildApiUrl(path: string): string {
|
|||
// function ensureTrailingSlash(value: string): string {
|
||||
// return value.endsWith('/') ? value : `${value}/`
|
||||
// }
|
||||
// function trimTrailingSlash(value: string): string {
|
||||
// if (value.length > 1 && value.endsWith('/')) {
|
||||
// return value.slice(0, -1)
|
||||
// }
|
||||
// return value
|
||||
// }
|
||||
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
try {
|
||||
|
|
@ -347,7 +353,7 @@ export const authApi = {
|
|||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
const response = await fetch(withBaseUrl('/api/v1/auth/logout'), {
|
||||
const response = await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: withCsrf(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useMemo } from 'react'
|
||||
import { common, createLowlight } from 'lowlight'
|
||||
|
||||
// Create lowlight instance with common languages
|
||||
|
|
@ -14,30 +15,28 @@ interface CodeRendererProps {
|
|||
/**
|
||||
* Renders code with syntax highlighting using lowlight (highlight.js wrapper).
|
||||
* Reuses the same styling as Markdown code blocks for visual consistency.
|
||||
* Memoized to prevent re-highlighting on every render.
|
||||
*/
|
||||
export function CodeRenderer({ code, language, className }: CodeRendererProps) {
|
||||
let highlightedCode: string
|
||||
|
||||
try {
|
||||
if (language && lowlight.registered(language)) {
|
||||
// Highlight with specified language
|
||||
const tree = lowlight.highlight(language, code, { prefix: 'hljs-' })
|
||||
highlightedCode = treeToHtml(tree)
|
||||
} else {
|
||||
// Fallback to plain text (no highlighting)
|
||||
highlightedCode = escapeHtml(code)
|
||||
// Cache syntax highlighting result
|
||||
const highlightedCode = useMemo(() => {
|
||||
try {
|
||||
if (language && lowlight.registered(language)) {
|
||||
const tree = lowlight.highlight(language, code, { prefix: 'hljs-' })
|
||||
return treeToHtml(tree)
|
||||
}
|
||||
return escapeHtml(code)
|
||||
} catch (error) {
|
||||
console.error('Syntax highlighting failed:', error)
|
||||
return escapeHtml(code)
|
||||
}
|
||||
} catch (error) {
|
||||
// If highlighting fails, escape HTML and display as plain text
|
||||
console.error('Syntax highlighting failed:', error)
|
||||
highlightedCode = escapeHtml(code)
|
||||
}
|
||||
}, [code, language])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Reuse the same wrapper styling as Markdown code blocks */}
|
||||
<div className="my-6 rounded-2xl border border-border/60 bg-gradient-to-br from-secondary/45 via-background to-secondary/20 p-1 shadow-sm">
|
||||
<div className="max-w-full overflow-x-auto rounded-xl bg-background/80 px-4 py-4 backdrop-blur-sm">
|
||||
{/* Simplified styling - removed gradient, blur, and shadow for better performance */}
|
||||
<div className="my-4 rounded-lg border border-border/60 bg-secondary/30">
|
||||
<div className="max-w-full overflow-x-auto rounded-lg bg-background px-4 py-3">
|
||||
<pre className="m-0 min-w-max bg-transparent p-0 text-[13px] leading-6">
|
||||
<code
|
||||
className="hljs"
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ import * as mod from './file-tree-node'
|
|||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*
|
||||
* Note: FileTreeNodeComponent is wrapped with React.memo, so typeof returns 'object'
|
||||
* instead of 'function'. We check for both to handle the memo wrapper.
|
||||
*/
|
||||
describe('file-tree-node module exports', () => {
|
||||
it('exports the FileTreeNodeComponent component', () => {
|
||||
expect(mod.FileTreeNodeComponent).toBeDefined()
|
||||
expect(typeof mod.FileTreeNodeComponent).toBe('function')
|
||||
// React.memo wraps the component in an object, so typeof is 'object'
|
||||
expect(['function', 'object']).toContain(typeof mod.FileTreeNodeComponent)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { useState, memo, useMemo } from 'react'
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText, FileCode, File } from 'lucide-react'
|
||||
import type { FileTreeNode } from './file-tree-builder'
|
||||
import { getFileIcon } from './file-type-utils'
|
||||
|
|
@ -33,14 +33,23 @@ function formatFileSize(bytes: number): string {
|
|||
/**
|
||||
* Recursive file tree node component.
|
||||
* Renders either a file or directory node with expand/collapse functionality.
|
||||
* Memoized to prevent unnecessary re-renders when parent updates.
|
||||
*/
|
||||
export function FileTreeNodeComponent({ node, onFileClick, defaultExpanded = false }: FileTreeNodeProps) {
|
||||
export const FileTreeNodeComponent = memo(function FileTreeNodeComponent({
|
||||
node,
|
||||
onFileClick,
|
||||
defaultExpanded = false,
|
||||
}: FileTreeNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
||||
|
||||
// Cache icon computation
|
||||
const IconComponent = useMemo(
|
||||
() => getIconComponent(getFileIcon(node.name)),
|
||||
[node.name]
|
||||
)
|
||||
|
||||
// Render file node
|
||||
if (node.type === 'file') {
|
||||
const IconComponent = getIconComponent(getFileIcon(node.name))
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 hover:bg-accent/10 cursor-pointer transition-colors group"
|
||||
|
|
@ -94,4 +103,4 @@ export function FileTreeNodeComponent({ node, onFileClick, defaultExpanded = fal
|
|||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useMemo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Folder } from 'lucide-react'
|
||||
import type { SkillFile } from '@/api/types'
|
||||
|
|
@ -18,13 +19,19 @@ interface FileTreeProps {
|
|||
*/
|
||||
export function FileTree({ files, onFileClick, bare }: FileTreeProps) {
|
||||
const { t } = useTranslation()
|
||||
const tree = buildFileTree(files)
|
||||
|
||||
const handleFileClick = (node: FileTreeNode) => {
|
||||
if (node.type === 'file' && onFileClick) {
|
||||
onFileClick(node)
|
||||
}
|
||||
}
|
||||
// Cache tree structure to avoid rebuilding on every render
|
||||
const tree = useMemo(() => buildFileTree(files), [files])
|
||||
|
||||
// Stable callback reference to prevent child re-renders
|
||||
const handleFileClick = useCallback(
|
||||
(node: FileTreeNode) => {
|
||||
if (node.type === 'file' && onFileClick) {
|
||||
onFileClick(node)
|
||||
}
|
||||
},
|
||||
[onFileClick]
|
||||
)
|
||||
|
||||
const treeContent = (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
|
|
@ -17,6 +18,7 @@ interface MarkdownRendererProps {
|
|||
* Renders markdown from skill packages using a constrained plugin stack.
|
||||
* Frontmatter is stripped before render because package metadata is surfaced in
|
||||
* dedicated UI sections and should not appear twice in the document body.
|
||||
* Memoized to prevent re-parsing on every render.
|
||||
*/
|
||||
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
const containerClassName = [
|
||||
|
|
@ -25,7 +27,12 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
|
|||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
const normalizedContent = stripMarkdownFrontmatter(content)
|
||||
|
||||
// Cache the normalized content to prevent re-parsing on every render
|
||||
const normalizedContent = useMemo(
|
||||
() => stripMarkdownFrontmatter(content),
|
||||
[content]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
|
|
@ -103,8 +110,8 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
|
|||
</li>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<div className="my-6 rounded-2xl border border-border/60 bg-gradient-to-br from-secondary/45 via-background to-secondary/20 p-1 shadow-sm">
|
||||
<div className="max-w-full overflow-x-auto rounded-xl bg-background/80 px-4 py-4 backdrop-blur-sm">
|
||||
<div className="my-4 rounded-lg border border-border/60 bg-secondary/30">
|
||||
<div className="max-w-full overflow-x-auto rounded-lg bg-background px-4 py-3">
|
||||
<pre className="m-0 min-w-max bg-transparent p-0 text-[13px] leading-6">{children}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -132,7 +139,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
|
|||
blockquote: ({ className: blockquoteClassName, children, ...props }) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
'relative my-6 overflow-hidden rounded-r-xl border-l-4 border-l-primary/35 bg-secondary/30 px-5 py-4 text-foreground/80 shadow-sm',
|
||||
'my-4 border-l-4 border-l-primary/40 bg-secondary/20 px-4 py-3 text-foreground/80',
|
||||
blockquoteClassName
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -144,7 +151,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
|
|||
<hr className={cn('my-10 mx-auto w-full max-w-full border-border/50', hrClassName)} {...props} />
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="my-6 overflow-hidden rounded-2xl border border-border/80 bg-card/80 shadow-sm">
|
||||
<div className="my-4 overflow-hidden rounded-lg border border-border/60 bg-card/80">
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<table className="m-0 min-w-full border-separate border-spacing-0 text-sm">{children}</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useParams: () => ({ slug: 'test-ns' }),
|
||||
|
|
@ -19,10 +21,6 @@ vi.mock('@/shared/lib/date-time', () => ({
|
|||
formatLocalDateTime: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
|
@ -42,12 +40,22 @@ vi.mock('@/shared/ui/tabs', () => ({
|
|||
TabsTrigger: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useNamespaceDetail: () => ({ data: null, isLoading: false }),
|
||||
const paginationProps: Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }> = []
|
||||
vi.mock('@/shared/components/pagination', () => ({
|
||||
Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => {
|
||||
paginationProps.push(props)
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
||||
const useNamespaceDetailMock = vi.fn()
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useNamespaceDetail: (...args: unknown[]) => useNamespaceDetailMock(...args),
|
||||
}))
|
||||
|
||||
const useReviewListMock = vi.fn()
|
||||
vi.mock('@/features/review/use-review-list', () => ({
|
||||
useReviewList: () => ({ data: null, isLoading: false }),
|
||||
useReviewList: (...args: unknown[]) => useReviewListMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
|
|
@ -61,7 +69,89 @@ vi.mock('@/features/namespace/namespace-header', () => ({
|
|||
import { NamespaceReviewsPage } from './namespace-reviews'
|
||||
|
||||
describe('NamespaceReviewsPage', () => {
|
||||
function createReviewItem(id: number) {
|
||||
return {
|
||||
id,
|
||||
namespace: 'demo-ns',
|
||||
skillSlug: `skill-${id}`,
|
||||
version: '1.0.0',
|
||||
submittedBy: 'user-1',
|
||||
submittedByName: 'User 1',
|
||||
submittedAt: '2026-04-01T12:00:00Z',
|
||||
reviewedBy: null,
|
||||
reviewedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewComment: null,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
paginationProps.length = 0
|
||||
useNamespaceDetailMock.mockReset()
|
||||
useReviewListMock.mockReset()
|
||||
|
||||
useNamespaceDetailMock.mockReturnValue({
|
||||
data: {
|
||||
id: 100,
|
||||
slug: 'test-ns',
|
||||
displayName: 'Test Namespace',
|
||||
type: 'CUSTOM',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
|
||||
if (!enabled || status !== 'PENDING') {
|
||||
return { data: null, isLoading: false }
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
items: [createReviewItem(1)],
|
||||
totalElements: 11,
|
||||
totalPages: 2,
|
||||
page,
|
||||
size: 10,
|
||||
total: 11,
|
||||
},
|
||||
isLoading: false,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof NamespaceReviewsPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders pagination for namespace review list when totalPages > 1', () => {
|
||||
const html = renderToStaticMarkup(createElement(NamespaceReviewsPage))
|
||||
|
||||
expect(html).toContain('nsReviews.pageSummary')
|
||||
expect(paginationProps).toHaveLength(1)
|
||||
expect(paginationProps[0]?.page).toBe(0)
|
||||
expect(paginationProps[0]?.totalPages).toBe(2)
|
||||
})
|
||||
|
||||
it('does not render pagination when there is only one page', () => {
|
||||
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
|
||||
if (!enabled || status !== 'PENDING') {
|
||||
return { data: null, isLoading: false }
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
items: [createReviewItem(2)],
|
||||
totalElements: 1,
|
||||
totalPages: 1,
|
||||
page,
|
||||
size: 10,
|
||||
total: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
}
|
||||
})
|
||||
|
||||
renderToStaticMarkup(createElement(NamespaceReviewsPage))
|
||||
|
||||
expect(paginationProps).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import { useState } from 'react'
|
|||
import { useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries'
|
||||
import { useReviewList } from '@/features/review/use-review-list'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { NamespaceHeader } from '@/features/namespace/namespace-header'
|
||||
|
||||
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
|
|
@ -51,26 +51,7 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
|
|||
return (
|
||||
<div className="flex flex-col gap-3 border-t border-border/60 px-5 py-4 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
|
||||
<p>{t('nsReviews.pageSummary', { total: totalElements, page: currentPage + 1 })}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 0}
|
||||
onClick={() => changePage(status, currentPage - 1)}
|
||||
>
|
||||
{t('nsReviews.prevPage')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
onClick={() => changePage(status, currentPage + 1)}
|
||||
>
|
||||
{t('nsReviews.nextPage')}
|
||||
</Button>
|
||||
</div>
|
||||
<Pagination page={currentPage} totalPages={totalPages} onPageChange={(nextPage) => changePage(status, nextPage)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
|
|
@ -19,10 +21,6 @@ vi.mock('react-i18next', async () => {
|
|||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
CardContent: ({ children }: { children: unknown }) => children,
|
||||
|
|
@ -55,12 +53,22 @@ vi.mock('@/shared/ui/table', () => ({
|
|||
TableRow: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/review/use-review-list', () => ({
|
||||
useReviewList: () => ({ data: null, isLoading: false }),
|
||||
const paginationProps: Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }> = []
|
||||
vi.mock('@/shared/components/pagination', () => ({
|
||||
Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => {
|
||||
paginationProps.push(props)
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
||||
const useReviewListMock = vi.fn()
|
||||
vi.mock('@/features/review/use-review-list', () => ({
|
||||
useReviewList: (...args: unknown[]) => useReviewListMock(...args),
|
||||
}))
|
||||
|
||||
const hasRoleMock = vi.fn()
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({ hasRole: () => false }),
|
||||
useAuth: () => ({ hasRole: hasRoleMock }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
|
|
@ -78,7 +86,86 @@ vi.mock('./profile-review-table', () => ({
|
|||
import { ReviewsPage } from './reviews'
|
||||
|
||||
describe('ReviewsPage', () => {
|
||||
function createReviewItem(id: number) {
|
||||
return {
|
||||
id,
|
||||
namespace: 'demo',
|
||||
skillSlug: `skill-${id}`,
|
||||
version: '1.0.0',
|
||||
submittedBy: 'user-1',
|
||||
submittedByName: 'User 1',
|
||||
submittedAt: '2026-04-01T12:00:00Z',
|
||||
reviewedBy: null,
|
||||
reviewedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewComment: null,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
paginationProps.length = 0
|
||||
hasRoleMock.mockReset()
|
||||
useReviewListMock.mockReset()
|
||||
hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN')
|
||||
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
return { data: null, isLoading: false }
|
||||
}
|
||||
|
||||
if (status === 'PENDING') {
|
||||
return {
|
||||
data: {
|
||||
items: [createReviewItem(1)],
|
||||
totalElements: 21,
|
||||
totalPages: 2,
|
||||
page,
|
||||
size: 20,
|
||||
total: 21,
|
||||
},
|
||||
isLoading: false,
|
||||
}
|
||||
}
|
||||
|
||||
return { data: null, isLoading: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof ReviewsPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders pagination for pending reviews when totalPages > 1', () => {
|
||||
const html = renderToStaticMarkup(createElement(ReviewsPage))
|
||||
|
||||
expect(html).toContain('reviews.pageSummary')
|
||||
expect(paginationProps).toHaveLength(1)
|
||||
expect(paginationProps[0]?.page).toBe(0)
|
||||
expect(paginationProps[0]?.totalPages).toBe(2)
|
||||
})
|
||||
|
||||
it('renders disabled-style pagination when there is only one page', () => {
|
||||
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
|
||||
if (!enabled || status !== 'PENDING') {
|
||||
return { data: null, isLoading: false }
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
items: [createReviewItem(2)],
|
||||
totalElements: 1,
|
||||
totalPages: 1,
|
||||
page,
|
||||
size: 20,
|
||||
total: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
}
|
||||
})
|
||||
|
||||
renderToStaticMarkup(createElement(ReviewsPage))
|
||||
|
||||
expect(paginationProps).toHaveLength(1)
|
||||
expect(paginationProps[0]?.page).toBe(0)
|
||||
expect(paginationProps[0]?.totalPages).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useState } from 'react'
|
|||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { FileCheck2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
|
|
@ -17,6 +16,7 @@ import {
|
|||
import { useReviewList } from '@/features/review/use-review-list'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { ProfileReviewTable } from './profile-review-table'
|
||||
|
||||
|
|
@ -72,31 +72,11 @@ export function ReviewsPage() {
|
|||
}
|
||||
|
||||
function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) {
|
||||
if (totalPages <= 1) return null
|
||||
const currentPage = pages[status]
|
||||
return (
|
||||
<div className="flex flex-col gap-3 border-t border-border/60 px-6 py-4 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
|
||||
<p>{t('reviews.pageSummary', { total: totalElements, page: currentPage + 1 })}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 0}
|
||||
onClick={() => changePage(status, currentPage - 1)}
|
||||
>
|
||||
{t('reviews.prevPage')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
onClick={() => changePage(status, currentPage + 1)}
|
||||
>
|
||||
{t('reviews.nextPage')}
|
||||
</Button>
|
||||
</div>
|
||||
<Pagination page={currentPage} totalPages={Math.max(totalPages, 1)} onPageChange={(nextPage) => changePage(status, nextPage)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue