fix: 修复文档多处问题 & feat: 新增相关文档推荐功能 #86
|
|
@ -1,62 +0,0 @@
|
|||
version: 2
|
||||
name: 未命名项目
|
||||
description: ""
|
||||
global:
|
||||
concurrent: 1
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: push
|
||||
ruleset-operator: AND
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
- ref: git_clone_0
|
||||
name: git clone
|
||||
task: git_clone@1.2.9
|
||||
input:
|
||||
username: ((ganshihao21.ganshihao21_user))
|
||||
password: ((ganshihao21.ganshihao21))
|
||||
remote_url: '"https://gitlink.org.cn/ganshihao21/reposync.git"'
|
||||
ref: '"refs/heads/master"'
|
||||
commit_id: '""'
|
||||
depth: 1
|
||||
needs:
|
||||
- start
|
||||
- ref: docker_image_build_0
|
||||
name: docker镜像构建
|
||||
task: docker_image_build@1.6.0
|
||||
input:
|
||||
docker_username: ((nudt_devops.nudt_devops))
|
||||
docker_password: ((nudt_devops.nudt_devops))
|
||||
image_name: '"registry.cn-guangzhou.aliyuncs.com/nudt_devops/reposync_group5"'
|
||||
image_tag: '"latest"'
|
||||
registry_address: '"registry.cn-guangzhou.aliyuncs.com"'
|
||||
docker_file: '"Dockerfile"'
|
||||
docker_build_path: '"."'
|
||||
workspace: git_clone_0.git_path
|
||||
image_push: true
|
||||
build_args: '""'
|
||||
needs:
|
||||
- git_clone_0
|
||||
- ref: ssh_cmd_0
|
||||
name: ssh执行命令
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_pass: ((ssh_key.ssh_key))
|
||||
ssh_ip: '"47.96.164.145"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_cmd: '"docker stop abc_group5 && docker rm abc_group5 && docker pull
|
||||
registry.cn-guangzhou.aliyuncs.com/nudt_devops/reposync_group5:latest &&
|
||||
docker run -d -p 3000:3000 --name abc_group5
|
||||
registry.cn-guangzhou.aliyuncs.com/nudt_devops/reposync_group5:latest"'
|
||||
needs:
|
||||
- docker_image_build_0
|
||||
- ref: end
|
||||
name: 结束
|
||||
task: end
|
||||
needs:
|
||||
- ssh_cmd_0
|
||||
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
version: 2
|
||||
name: 构建流水线
|
||||
description: ""
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: push
|
||||
ruleset-operator: AND
|
||||
global:
|
||||
concurrent: 1
|
||||
param:
|
||||
- ref: remote_url
|
||||
name: ""
|
||||
value: '"https://gitlink.org.cn/z2_cc/gitlink_help_center.git"'
|
||||
required: false
|
||||
type: STRING
|
||||
hidden: false
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
- ref: git_clone_0
|
||||
name: git clone
|
||||
task: git_clone@1.2.9
|
||||
input:
|
||||
username: ((work_together.gitlink_username))
|
||||
password: ((work_together.gitlink_key))
|
||||
remote_url: '"https://gitlink.org.cn/z2_cc/gitlink_help_center.git"'
|
||||
ref: '"refs/heads/master"'
|
||||
commit_id: '""'
|
||||
depth: 1
|
||||
needs:
|
||||
- start
|
||||
- ref: docker_image_build_0
|
||||
name: docker镜像构建
|
||||
task: docker_image_build@1.6.0
|
||||
input:
|
||||
docker_username: ((work_together.docker_name))
|
||||
docker_password: ((work_together.docker_password))
|
||||
image_name: '"crpi-kn808rklyotb8pg1.cn-hangzhou.personal.cr.aliyuncs.com/gitlink_nudter/help_center"'
|
||||
image_tag: '"latest"'
|
||||
registry_address: '"crpi-kn808rklyotb8pg1.cn-hangzhou.personal.cr.aliyuncs.com/gitlink_nudter/help_center"'
|
||||
docker_file: '"Dockerfile"'
|
||||
docker_build_path: git_clone_0.git_path
|
||||
workspace: git_clone_0.git_path
|
||||
image_push: true
|
||||
build_args: '"BUILD_DATE=$(date +%s)"'
|
||||
needs:
|
||||
- git_clone_0
|
||||
- ref: ssh_cmd_0
|
||||
name: ssh执行命令
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_pass: ((work_together.ssh_key))
|
||||
ssh_ip: '"121.41.216.243"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_cmd: '"docker stop help_center || true && docker rm help_center || true
|
||||
&& docker pull crpi-kn808rklyotb8pg1.cn-hangzhou.personal.cr.aliyuncs.com/gitlink_nudter/help_center:latest
|
||||
&& mkdir -p /root/help_center_data
|
||||
&& docker run -d -p 3000:3000 --env-file /root/.help_center_env
|
||||
-v /root/help_center_data:/gitlink_help_center/data
|
||||
--name help_center --restart unless-stopped
|
||||
crpi-kn808rklyotb8pg1.cn-hangzhou.personal.cr.aliyuncs.com/gitlink_nudter/help_center:latest"'
|
||||
needs:
|
||||
- docker_image_build_0
|
||||
- ref: end
|
||||
name: 结束
|
||||
task: end
|
||||
needs:
|
||||
- ssh_cmd_0
|
||||
|
||||
|
|
@ -21,3 +21,17 @@ deploy.sh
|
|||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Office temp files
|
||||
.~*
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
# Generated search index
|
||||
static/doc-index.json
|
||||
|
||||
# Feedback data
|
||||
data/feedback.json
|
||||
|
|
|
|||
17
Dockerfile
|
|
@ -1,10 +1,19 @@
|
|||
FROM node:18-alpine
|
||||
FROM crpi-kn808rklyotb8pg1.cn-hangzhou.personal.cr.aliyuncs.com/gitlink_nudter/help_center:node-20-alpine
|
||||
LABEL maintainer="yuankaifneg <2894340009@qq.com>"
|
||||
|
||||
WORKDIR /gitlink_help_center
|
||||
|
||||
COPY ./ /gitlink_help_center/
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
RUN yarn install
|
||||
COPY ./ /gitlink_help_center/
|
||||
RUN npm run build -- --locale zh-cn
|
||||
CMD ["npm", "run", "serve"]
|
||||
RUN node scripts/generate-doc-index.js
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
RUN mkdir -p /gitlink_help_center/data
|
||||
|
||||
VOLUME ["/gitlink_help_center/data"]
|
||||
|
||||
CMD ["npx", "pm2-runtime", "ecosystem.config.js"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
// api/feedback/store.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DATA_DIR = path.resolve(__dirname, '../../data');
|
||||
const FEEDBACK_FILE = path.join(DATA_DIR, 'feedback.json');
|
||||
const MAX_ENTRIES = 10000;
|
||||
|
||||
function ensureFile() {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(FEEDBACK_FILE)) {
|
||||
fs.writeFileSync(FEEDBACK_FILE, '[]', 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
function readAll() {
|
||||
ensureFile();
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(entries) {
|
||||
ensureFile();
|
||||
fs.writeFileSync(FEEDBACK_FILE, JSON.stringify(entries, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function addFeedback({ docPath, vote, suggestion }) {
|
||||
const entries = readAll();
|
||||
const entry = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
docPath,
|
||||
vote,
|
||||
suggestion: suggestion || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
entries.push(entry);
|
||||
if (entries.length > MAX_ENTRIES) {
|
||||
entries.splice(0, entries.length - MAX_ENTRIES);
|
||||
}
|
||||
writeAll(entries);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
const entries = readAll();
|
||||
const total = entries.length;
|
||||
const yes = entries.filter(e => e.vote === 'yes').length;
|
||||
const no = entries.filter(e => e.vote === 'no').length;
|
||||
const withSuggestion = entries.filter(e => e.suggestion).length;
|
||||
|
||||
const byPath = {};
|
||||
for (const e of entries) {
|
||||
if (!byPath[e.docPath]) {
|
||||
byPath[e.docPath] = { path: e.docPath, yes: 0, no: 0, suggestions: [] };
|
||||
}
|
||||
if (e.vote === 'yes') byPath[e.docPath].yes++;
|
||||
else byPath[e.docPath].no++;
|
||||
if (e.suggestion) {
|
||||
byPath[e.docPath].suggestions.push({ text: e.suggestion, time: e.timestamp });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
yes,
|
||||
no,
|
||||
helpfulRate: total > 0 ? Math.round((yes / total) * 100) : 0,
|
||||
withSuggestion,
|
||||
byPath: Object.values(byPath).sort((a, b) => (b.yes + b.no) - (a.yes + a.no)),
|
||||
};
|
||||
}
|
||||
|
||||
function getRecent(limit = 50) {
|
||||
const entries = readAll();
|
||||
return entries.slice(-limit).reverse();
|
||||
}
|
||||
|
||||
module.exports = { addFeedback, getStats, getRecent };
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
// api/llm/deepseek.js
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
function createStream(messages, options = {}) {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY;
|
||||
const baseUrl = process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com';
|
||||
const model = process.env.DEEPSEEK_CHAT_MODEL || 'deepseek-chat';
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('DEEPSEEK_API_KEY environment variable is required');
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
temperature: options.temperature || 0.7,
|
||||
max_tokens: options.maxTokens || 2048,
|
||||
});
|
||||
|
||||
const url = new URL('/chat/completions', baseUrl);
|
||||
const transport = url.protocol === 'https:' ? https : http;
|
||||
|
||||
const reqOptions = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
};
|
||||
|
||||
return { reqOptions, transport, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream DeepSeek chat completions as an async iterator of content strings.
|
||||
* @param {Array<{role: string, content: string}>} messages
|
||||
* @param {object} options
|
||||
* @yields {string} content delta
|
||||
*/
|
||||
async function* streamChat(messages, options = {}) {
|
||||
const { reqOptions, transport, body } = createStream(messages, options);
|
||||
|
||||
const res = await new Promise((resolve, reject) => {
|
||||
const req = transport.request(reqOptions, resolve);
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
const errBody = await new Promise(r => {
|
||||
let data = '';
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => r(data));
|
||||
});
|
||||
throw new Error(`DeepSeek API error ${res.statusCode}: ${errBody}`);
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
for await (const chunk of res) {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
||||
const data = trimmed.slice(6);
|
||||
if (data === '[DONE]') return;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices?.[0]?.delta?.content;
|
||||
if (content) yield content;
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { streamChat };
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// api/search/__tests__/bm25.test.js
|
||||
const { bm25 } = require('../bm25');
|
||||
|
||||
describe('bm25', () => {
|
||||
const docs = [
|
||||
{ tokens: ['合并', '请求', '创建', '代码'], text: '如何创建合并请求', breadcrumb: '合并请求 > 创建合并请求', url: '/合并请求/创建合并请求' },
|
||||
{ tokens: ['代码', '仓库', '管理', '设置'], text: '代码仓库管理设置', breadcrumb: '代码库管理 > 仓库设置', url: '/代码库管理/仓库设置' },
|
||||
{ tokens: ['组织', '创建', '管理', '成员'], text: '组织创建管理成员', breadcrumb: '组织管理 > 创建组织', url: '/组织管理/创建组织' },
|
||||
{ tokens: ['疑修', '创建', '问题', '跟踪'], text: '疑修创建问题跟踪', breadcrumb: '疑修 > 创建疑修', url: '/疑修/创建疑修' },
|
||||
];
|
||||
|
||||
test('returns docs sorted by relevance', () => {
|
||||
const results = bm25(['合并', '请求'], docs, 2);
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0].breadcrumb).toBe('合并请求 > 创建合并请求');
|
||||
});
|
||||
|
||||
test('returns empty array for no matches', () => {
|
||||
const results = bm25(['不存在的关键词'], docs, 3);
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('respects topK limit', () => {
|
||||
const results = bm25(['创建'], docs, 2);
|
||||
expect(results.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
// api/search/bm25.js
|
||||
|
||||
/**
|
||||
* BM25 ranking. Returns top-K docs sorted by relevance score descending.
|
||||
* @param {string[]} queryTokens - tokenized query
|
||||
* @param {Array<{tokens: string[], text: string, breadcrumb: string, url: string}>} docs
|
||||
* @param {number} topK
|
||||
*/
|
||||
function bm25(queryTokens, docs, topK = 5) {
|
||||
const k1 = 1.5;
|
||||
const b = 0.75;
|
||||
|
||||
// Average document length
|
||||
const avgDocLen = docs.reduce((sum, d) => sum + d.tokens.length, 0) / docs.length;
|
||||
|
||||
// Document frequency for each token
|
||||
const df = {};
|
||||
for (const token of queryTokens) {
|
||||
df[token] = docs.filter(d => d.tokens.includes(token)).length;
|
||||
}
|
||||
|
||||
const N = docs.length;
|
||||
const scored = docs.map(doc => {
|
||||
let score = 0;
|
||||
const docLen = doc.tokens.length;
|
||||
for (const token of queryTokens) {
|
||||
const tf = doc.tokens.filter(t => t === token).length;
|
||||
const idf = Math.log((N - (df[token] || 0) + 0.5) / ((df[token] || 0) + 0.5) + 1);
|
||||
const numerator = tf * (k1 + 1);
|
||||
const denominator = tf + k1 * (1 - b + b * (docLen / avgDocLen));
|
||||
score += idf * (numerator / denominator);
|
||||
}
|
||||
return { ...doc, score };
|
||||
});
|
||||
|
||||
return scored
|
||||
.filter(d => d.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, topK);
|
||||
}
|
||||
|
||||
module.exports = { bm25 };
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// api/search/indexer.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { bm25 } = require('./bm25');
|
||||
|
||||
let jieba;
|
||||
try {
|
||||
jieba = require('@node-rs/jieba');
|
||||
} catch {
|
||||
jieba = { cut: (text) => [...new Set(text.replace(/[^一-鿿a-zA-Z0-9]/g, ' ').split(/\s+/).filter(t => t.length > 1))] };
|
||||
}
|
||||
|
||||
let index = null;
|
||||
|
||||
function loadIndex() {
|
||||
if (index) return index;
|
||||
const indexPath = path.join(__dirname, '..', '..', 'static', 'doc-index.json');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
throw new Error('doc-index.json not found. Run `node scripts/generate-doc-index.js` first.');
|
||||
}
|
||||
index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
||||
console.log(`Loaded search index: ${index.length} documents`);
|
||||
return index;
|
||||
}
|
||||
|
||||
function tokenizeQuery(query) {
|
||||
return jieba.cut(query).filter(t => t.length > 1 && !/^[\s\d\p{P}]+$/u.test(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Search documents relevant to a query.
|
||||
* @param {string} query - user's question
|
||||
* @param {number} topK - max results
|
||||
* @returns {Array<{title: string, text: string, breadcrumb: string, url: string, score: number}>}
|
||||
*/
|
||||
function search(query, topK = 3) {
|
||||
const docs = loadIndex();
|
||||
const tokens = tokenizeQuery(query);
|
||||
if (tokens.length === 0) return [];
|
||||
return bm25(tokens, docs, topK);
|
||||
}
|
||||
|
||||
module.exports = { search, loadIndex };
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
// api/server.js
|
||||
const express = require('express');
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
const { search } = require('./search/indexer');
|
||||
const { streamChat } = require('./llm/deepseek');
|
||||
const { addFeedback, getStats, getRecent } = require('./feedback/store');
|
||||
|
||||
const API_PORT = parseInt(process.env.API_PORT || '3000', 10);
|
||||
const SERVE_PORT = parseInt(process.env.SERVE_PORT || '3001', 10);
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'gitlink_admin_2024';
|
||||
const MAX_HISTORY = 10;
|
||||
|
||||
const SYSTEM_PROMPT = `你是 GitLink 帮助中心的 AI 助手。请基于提供的文档内容回答用户的问题。
|
||||
|
||||
要求:
|
||||
1. 回答要简洁明了,直接回答用户的问题
|
||||
2. 如果提供的文档中没有相关内容,请诚实说明
|
||||
3. 回答时引用具体的操作步骤和页面位置`;
|
||||
|
||||
function buildMessages(query, history, searchResults) {
|
||||
const messages = [{ role: 'system', content: SYSTEM_PROMPT }];
|
||||
|
||||
if (searchResults.length > 0) {
|
||||
const context = searchResults.map((doc, i) =>
|
||||
`[文档${i + 1}] 标题: ${doc.title}\n路径: ${doc.breadcrumb}\nURL: ${doc.url}\n内容:\n${doc.text}`
|
||||
).join('\n\n---\n\n');
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: `以下是相关的帮助中心文档内容:\n\n${context}`,
|
||||
});
|
||||
}
|
||||
|
||||
const recentHistory = history.slice(-MAX_HISTORY);
|
||||
for (const msg of recentHistory) {
|
||||
messages.push({ role: msg.role, content: msg.content });
|
||||
}
|
||||
|
||||
messages.push({ role: 'user', content: query });
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// Chat endpoint (SSE streaming)
|
||||
app.post('/api/chat', async (req, res) => {
|
||||
const { message, history = [] } = req.body;
|
||||
|
||||
if (!message || typeof message !== 'string') {
|
||||
return res.status(400).json({ error: 'message is required' });
|
||||
}
|
||||
|
||||
let searchResults = [];
|
||||
try {
|
||||
searchResults = search(message, 3);
|
||||
} catch (err) {
|
||||
console.error('Search error:', err.message);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
if (searchResults.length > 0) {
|
||||
const links = searchResults.map(doc => ({
|
||||
title: doc.title,
|
||||
path: encodeURI(doc.url),
|
||||
breadcrumb: doc.breadcrumb,
|
||||
}));
|
||||
res.write(`data: ${JSON.stringify({ type: 'sources', links })}\n\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = buildMessages(message, history, searchResults);
|
||||
for await (const chunk of streamChat(messages)) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'content', text: chunk })}\n\n`);
|
||||
}
|
||||
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
|
||||
} catch (err) {
|
||||
console.error('LLM error:', err.message);
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', message: 'AI 服务暂时不可用,请稍后重试' })}\n\n`);
|
||||
}
|
||||
|
||||
res.end();
|
||||
});
|
||||
|
||||
// Feedback: submit
|
||||
app.post('/api/feedback', (req, res) => {
|
||||
const { docPath, vote, suggestion } = req.body;
|
||||
if (!docPath || !vote || !['yes', 'no'].includes(vote)) {
|
||||
return res.status(400).json({ error: 'docPath and vote (yes/no) are required' });
|
||||
}
|
||||
const entry = addFeedback({ docPath, vote, suggestion });
|
||||
res.json({ success: true, id: entry.id });
|
||||
});
|
||||
|
||||
// Feedback: stats (admin)
|
||||
app.get('/api/feedback/stats', (req, res) => {
|
||||
const token = req.query.token || req.headers['x-admin-token'];
|
||||
if (token !== ADMIN_TOKEN) {
|
||||
return res.status(403).json({ error: 'unauthorized' });
|
||||
}
|
||||
res.json(getStats());
|
||||
});
|
||||
|
||||
// Feedback: recent entries (admin)
|
||||
app.get('/api/feedback/recent', (req, res) => {
|
||||
const token = req.query.token || req.headers['x-admin-token'];
|
||||
if (token !== ADMIN_TOKEN) {
|
||||
return res.status(403).json({ error: 'unauthorized' });
|
||||
}
|
||||
const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
|
||||
res.json(getRecent(limit));
|
||||
});
|
||||
|
||||
// Proxy all other requests to Docusaurus serve
|
||||
app.use('/', createProxyMiddleware({
|
||||
target: `http://localhost:${SERVE_PORT}`,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
}));
|
||||
|
||||
app.listen(API_PORT, () => {
|
||||
console.log(`API server listening on port ${API_PORT}, proxying to Docusaurus on port ${SERVE_PORT}`);
|
||||
});
|
||||
|
|
@ -12,7 +12,7 @@ sidebar_position: 4
|
|||

|
||||
|
||||
## 触发器
|
||||
注:流水线编排时,触发器要放在开始节点之前;目前一条流水线仅添加支持一个触发器
|
||||
注:流水线编排时,触发器要放在开始节点之前;目前一条流水线仅支持添加一个触发器
|
||||
|
||||
* 定时触发器cron:可通过填写cron表达式设置触发的具体时间
|
||||

|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
sidebar_label: 'HiAgent简介'
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# HiAgent 简介
|
||||
|
||||
HiAgent 是 GitLink 平台提供的 AI 智能助手功能,旨在通过 AI 技术帮助开发者更高效地完成平台操作。HiAgent 基于 CLI + Skills 双层架构设计,能够自动化执行多种平台操作。
|
||||
|
||||
## 功能入口
|
||||
|
||||
登录 GitLink 平台后,访问 **HiAgent** 页面(路径:`/hiagent`)即可进入 HiAgent 功能界面
|
||||
|
||||
## 核心能力
|
||||
|
||||
HiAgent 支持以下 AI 自动化操作:
|
||||
|
||||
- **仓库管理**:通过 AI 指令自动创建代码仓库
|
||||
- **疑修管理**:自动创建和管理疑修(Issue)
|
||||
- **代码评审**:辅助进行 Pull Request 的代码审查
|
||||
- **发布说明**:自动生成项目 Release Notes
|
||||
- **流水线管理**:管理 CI/CD 流水线配置与执行
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 进入 HiAgent 页面
|
||||
2. 在对话框中输入操作指令或选择预设技能
|
||||
3. HiAgent 将自动解析指令并执行相应的平台操作
|
||||
4. 查看执行结果并确认
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 使用 HiAgent 需要登录 GitLink 账号
|
||||
- 部分操作需要具备对应项目的相应权限
|
||||
- AI 生成的内容建议人工确认后再正式使用
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"label": "HiAgent",
|
||||
"position": 14
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ GitLink(确实开源)是CCF官方指定的开源创新服务平台,旨在
|
|||
<p>通知简介及设置[2个文档]</p>
|
||||
</a></article>
|
||||
<article class="col col--6 margin-bottom--lg">
|
||||
<a class="card padding--lg cardContainer" href="/个人主页建站/建站流程">
|
||||
<a class="card padding--lg cardContainer" href="/个人主页建站/站点创建流程">
|
||||
<h2 class="text--truncate cardTitle" title="个人主页建站">个人主页建站</h2>
|
||||
<p>个人主页建站使用及设置[2个文档]</p>
|
||||
</a></article>
|
||||
|
|
|
|||
|
|
@ -11,56 +11,56 @@ sidebar_position: 2
|
|||
|
||||
使用Hugo 工具创建的仓库,需要将代码仓库中config.toml 文件中的 baseURL 修改
|
||||
将值改成 个人建站服务中显示的网站地址,
|
||||
例如: 现在有一个网站地址为 http://KingChan.gitlink.net
|
||||
那么 config.toml 中的 baseURL 应该为 http://KingChan.gitlink.net
|
||||
例如: 现在有一个网站地址为 https://KingChan.gitlink.net
|
||||
那么 config.toml 中的 baseURL 应该为 https://KingChan.gitlink.net
|
||||

|
||||
|
||||
修改完毕后点击提交
|
||||

|
||||
提交完成后,开始部署
|
||||

|
||||

|
||||
静态页面预览
|
||||

|
||||

|
||||
|
||||
### jekyll工具
|
||||
|
||||
如果是jekyll项目:
|
||||
使用jekyll工具创建的个人站点,需要对代码仓库中 _config.yml文件中的 baseurl和url两个值进行修改,jekyll的配置文件比较特殊,需要根据 个人建站服务 中显示的 网站地址
|
||||
内容修改两个值。
|
||||
例如: 现在有一个网站地址为 http://KingChan.gitlink.net
|
||||
例如: 现在有一个网站地址为 https://KingChan.gitlink.net
|
||||
那么结果如下:
|
||||
baseurl: "/"
|
||||
url: "http://KingChan.gitlink.net"
|
||||
url: "https://KingChan.gitlink.net"
|
||||
|
||||

|
||||

|
||||
|
||||
修改完成后点击 提交变更
|
||||

|
||||
提交完成后,前往建站服务开始部署
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
### hexo工具
|
||||
|
||||
如果是 hexo项目:
|
||||
使用Hexo工具创建的仓库,需要将代码仓库中_config.yml 文件中的 url修改
|
||||
将值改成 个人建站服务中显示的网站地址,
|
||||
例如: 现在有一个网站地址为 http://KingChan.gitlink.net
|
||||
例如: 现在有一个网站地址为 https://KingChan.gitlink.net
|
||||
|
||||
那么 _config.toml 中的 url应该为 http://KingChan.gitlink.net
|
||||
那么 _config.yml 中的 url 应该为 https://KingChan.gitlink.net
|
||||
|
||||

|
||||

|
||||
|
||||
修改完毕后提交变更
|
||||

|
||||
提交完成后,前往建站服务开始部署
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
### file 工具
|
||||
文档格式工具,使用该工具会部署所选中分支下的所有文件。
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ sidebar_position: 9
|
|||
---
|
||||
|
||||
### **1. 语言服务**
|
||||
> 关于 WebIDE 的详细技术背景与完整功能说明,请参阅 [第三方服务 - WebIDE](/docs/第三方服务/WebIDE)。
|
||||
● 支持近 40 种语言的语法高亮
|
||||
● 支持 JavaScript/TypeScript、HTML、CSS、JSON、Markdown 的基于 LSP (Language Server Protocol) 语言特性功能,具备智能提示和大纲信息以及单文件内跳转。
|
||||
● 支持 Java、Go、Python、C++、Php 的基于 Tree Sitter 的在线语言服务能力,提供查看引用、符号搜索等特性
|
||||
|
|
|
|||
|
|
@ -7,5 +7,5 @@ sidebar_position: 3
|
|||
<br/>
|
||||
|
||||
### **2. 上传文件**
|
||||
进入上传文件页面后,如下图所示,拖动文件,选择提交变更的分支的单击”创建项目“按键即可完成创建。
|
||||
进入上传文件页面后,如下图所示,拖动文件,选择提交变更的分支,单击”提交变更”按钮即可完成上传。
|
||||
<br/>
|
||||
|
|
@ -7,7 +7,7 @@ sidebar_position: 5
|
|||
<br/>
|
||||
|
||||
### **2. 合并分支**
|
||||
在“和并请求(PR)”下点击“新建合并请求”后,
|
||||
在”合并请求(PR)”下点击”新建合并请求”后,
|
||||
<br/>
|
||||
如下图所示,用户可以选择源分支与目标分支以及填写分支信息后提交分支合并请求。
|
||||
<br/>
|
||||
|
|
@ -10,8 +10,8 @@ sidebar_position: 7
|
|||
进入成员管理模块后,可以通过”角色筛选“按键来筛选项目成员类型,通过”搜索“按键可以检索具体的项目成员,如下图所示。
|
||||
<br/>
|
||||
|
||||
### **3. 筛选、搜索项目成员**
|
||||
进入成员管理模块后,可以通过”添加成员“按键来添加项目成员,左侧搜索框检索到具体用户并选中后,单击”添加成员“按键即可成功添加项目成员。
|
||||
### **3. 添加项目成员**
|
||||
进入成员管理模块后,可以通过”添加成员”按键来添加项目成员,左侧搜索框检索到具体用户并选中后,单击”添加成员“按键即可成功添加项目成员。
|
||||
<br/>
|
||||
|
||||
### **4. 项目成员权限管理**
|
||||
|
|
|
|||
|
|
@ -3,4 +3,37 @@ sidebar_label: '合并请求关联疑修'
|
|||
sidebar_position: 5
|
||||
---
|
||||
|
||||
# 合并请求关联疑修
|
||||
# 合并请求关联疑修
|
||||
|
||||
在 GitLink 平台中,合并请求(Pull Request)可以与疑修(Issue)进行关联,方便团队成员追踪代码变更与任务之间的对应关系。
|
||||
|
||||
## 1. 关联方式
|
||||
|
||||
### 在合并请求中引用疑修
|
||||
|
||||
在创建或编辑合并请求时,可以在标题或描述中使用 `#疑修编号` 来引用对应的疑修。例如:
|
||||
|
||||
- 输入 `#12` 即可引用编号为 12 的疑修
|
||||
- 平台会自动将引用转化为可点击的链接,点击后可直接跳转到对应疑修详情页
|
||||
|
||||
### 通过关键词自动关闭疑修
|
||||
|
||||
在合并请求的描述中使用以下关键词,当合并请求被合并后,对应的疑修将自动关闭:
|
||||
|
||||
- `close #疑修编号`
|
||||
- `fix #疑修编号`
|
||||
- `resolve #疑修编号`
|
||||
|
||||
例如,在合并请求描述中填写 `本PR用于修复 close #15`,当该合并请求成功合并后,编号为 15 的疑修将自动变更为已关闭状态。
|
||||
|
||||
## 2. 关联效果
|
||||
|
||||
- 合并请求详情页会显示其关联的疑修列表
|
||||
- 疑修详情页的操作记录中会显示关联的合并请求信息
|
||||
- 当合并请求被合并时,通过关键词关联的疑修会自动关闭并记录关闭原因
|
||||
|
||||
## 3. 注意事项
|
||||
|
||||
- 疑修和合并请求需要属于同一个项目
|
||||
- 每个合并请求可以关联多个疑修
|
||||
- 自动关闭疑修仅在合并请求被合并时生效,关闭合并请求不会触发疑修关闭
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
---
|
||||
sidebar_label: '平台公告'
|
||||
sidebar_position: 1
|
||||
---
|
||||
---
|
||||
|
||||
# 平台公告
|
||||
|
||||
暂无平台公告,敬请期待。
|
||||
|
|
@ -5,13 +5,13 @@ sidebar_position: 3
|
|||
|
||||
# 提交第一行代码
|
||||
|
||||
# 1. 编辑代码
|
||||
## 1. 编辑代码
|
||||
|
||||
点击编辑按钮,开始编辑代码。
|
||||
|
||||

|
||||
|
||||
# 2. 提交代码
|
||||
## 2. 提交代码
|
||||
|
||||
在编辑框中编写代码,编写完成后填写变更信息后提交变更。
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ sidebar_position: 4
|
|||
|
||||
项目”模块汇聚和管理了所有 GitLink 平台上的托管项目和镜像项目,用户可以输入项目名称关键字进行搜索,也可以根据项目类别对项目进行筛选。
|
||||
|
||||
进入“项目”模块,左侧列出了项目类型和项目类别。其中,项目类型主要包括开源托管项目和开源镜像项目两类。项目类别主要包括:云计算、大数据、区块链、物联网、机器学习、人工智能、智慧医疗、其他。
|
||||
进入”项目”模块,左侧列出了项目类型和项目类别。其中,项目类型主要包括开源托管项目和开源镜像项目两类。项目类别涵盖多个领域,包括但不限于:人工智能、深度学习、云计算、大数据、云计算和大数据、区块链、物联网、自动驾驶、量子计算、智慧医疗、DevOps/运维/网管、数据库、程序开发、Web应用开发、开发工具、操作系统、云原生、游戏/娱乐、企业应用等。用户可根据实际需求选择相应类别进行筛选。
|
||||
|
||||

|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ sidebar_label: '模板导入及导出'
|
|||
sidebar_position: 2
|
||||
---
|
||||
|
||||
# 确实开源帮助中心
|
||||
# 模板导入及导出
|
||||
|
||||
## 模板的导入及导出管理
|
||||
#### 1.模板导入
|
||||
通过点击wiki界面的**导入模板**按钮即可导入本地的txt,markdown等格式文件进行Wiki文档的创建,当前平台此项功能尚在开发测试环节
|
||||
[](/img/wiki/img4.png)
|
||||
#### 2.模板导出
|
||||
当前平台此项功能尚在开发测试环节
|
||||
## 1. 模板导入
|
||||
|
||||
通过点击 Wiki 界面的**导入模板**按钮即可导入本地的 txt、markdown 等格式文件进行 Wiki 文档的创建。
|
||||
|
||||

|
||||
|
||||
## 2. 模板导出
|
||||
|
||||
当前平台此项功能尚在开发测试环节,敬请期待。
|
||||
<br/>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ sidebar_position: 1
|
|||
## 维基界面管理
|
||||
#### 1.维基界面
|
||||
GitLink项目安排了专门的**维基(Wiki)界面**来展示项目的一些情况
|
||||
[](/img/wiki/img1.png)
|
||||

|
||||
|
||||
#### 2.创建维基内容
|
||||
在初始化的维基界面当中点击**创建Wiki文档**或者**导入模板**即可进行Wiki文档的编辑
|
||||
|
|
@ -17,7 +17,7 @@ GitLink项目安排了专门的**维基(Wiki)界面**来展示项目的一些情
|
|||
|
||||
#### 3.进行Wiki文档的编辑
|
||||
点击**创建Wiki文档**后即跳转到编辑界面,初始内容为空白的文档
|
||||
[](/img/wiki/img3.png)
|
||||

|
||||
也可以通过勾选*添加模板*来进行模板的导入,平台提供的模板包括*周报*和*月报*的格式
|
||||
[](/img/wiki/img2.png)
|
||||

|
||||
完成编辑后点击左下角的的保存即可
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"label": "软件工厂",
|
||||
"position": 15
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
sidebar_label: '软件工厂简介'
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# 软件工厂简介
|
||||
|
||||
软件工厂是 GitLink 平台提供的项目管理仪表板,为团队提供一站式的项目研发状态可视化与管理能力。通过软件工厂,团队可以直观地掌握项目进度、任务分配、缺陷修复等核心指标。
|
||||
|
||||
## 功能入口
|
||||
|
||||
登录 GitLink 平台后,访问 **软件工厂** 页面(路径:`/softwareFactory/home`)即可进入软件工厂工作态势界面。
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 工作态势仪表板
|
||||
|
||||
软件工厂提供日/周/月维度的数据统计,包括:
|
||||
|
||||
- **任务统计**:完结任务数、待完成任务数、新建任务数
|
||||
- **缺陷统计**:关闭缺陷数、待修复缺陷数、新建缺陷数
|
||||
- **需求统计**:需求评审数、待评审需求数
|
||||
- **测试统计**:测试用例执行情况
|
||||
|
||||
### 项目研发状态
|
||||
|
||||
展示各项目的实时研发状态,包括:
|
||||
|
||||
- 最活跃项目排行
|
||||
- 项目任务量分布
|
||||
- 项目缺陷量分布
|
||||
|
||||
### 团队概览
|
||||
|
||||
展示团队成员的工作情况,包括:
|
||||
|
||||
- 个人任务量排名
|
||||
- 个人缺陷修复量排名
|
||||
|
||||
### 要闻与重点工作
|
||||
|
||||
- **要闻概览**:按月展示团队重要事件时间线
|
||||
- **重点工作**:聚焦当前阶段的核心工作事项
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 进入软件工厂页面,默认展示当前团队的工作态势总览
|
||||
2. 通过顶部导航切换不同的统计维度(日/周/月)
|
||||
3. 点击具体指标可查看详情
|
||||
4. 在要闻概览中可按月浏览团队重要动态
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// ecosystem.config.js
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'docusaurus',
|
||||
script: 'npx',
|
||||
args: 'docusaurus serve --port 3001',
|
||||
env: {
|
||||
SERVE_PORT: 3001,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'api',
|
||||
script: 'api/server.js',
|
||||
env: {
|
||||
API_PORT: 3000,
|
||||
SERVE_PORT: 3001,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
16
package.json
|
|
@ -11,25 +11,25 @@
|
|||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"build:api": "node scripts/generate-doc-index.js",
|
||||
"serve:api": "node api/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cmfcmf/docusaurus-search-local": "1.1.0",
|
||||
"@docusaurus/core": "3.9.2",
|
||||
"@docusaurus/plugin-client-redirects": "3.9.2",
|
||||
"@docusaurus/preset-classic": "3.9.2",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.35.0",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.44.0",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@node-rs/jieba": "^1.7.0",
|
||||
"clsx": "1.2.1",
|
||||
"docusaurus-gtm-plugin": "0.0.2",
|
||||
"docusaurus-plugin-image-zoom": "1.0.1",
|
||||
"or": "^0.2.0",
|
||||
"express": "^5.2.1",
|
||||
"http-proxy-middleware": "^4.0.0",
|
||||
"pm2": "^7.0.1",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-modal": "3.16.1"
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// @node-rs/jieba is already a project dependency
|
||||
let jieba;
|
||||
try {
|
||||
jieba = require('@node-rs/jieba');
|
||||
} catch {
|
||||
console.warn('Warning: @node-rs/jieba not available, using simple character split');
|
||||
jieba = { cut: (text) => [...new Set(text.replace(/[^一-龥a-zA-Z0-9]/g, ' ').split(/\s+/).filter(t => t.length > 1))] };
|
||||
}
|
||||
|
||||
const DOCS_DIR = path.join(__dirname, '..', 'docs');
|
||||
const OUTPUT_PATH = path.join(__dirname, '..', 'static', 'doc-index.json');
|
||||
|
||||
function stripMarkdown(md) {
|
||||
return md
|
||||
.replace(/^---[\s\S]*?---/m, '') // frontmatter
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // images
|
||||
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text
|
||||
.replace(/#{1,6}\s/g, '') // headings
|
||||
.replace(/(\*{1,3}|_{1,3})(.*?)\1/g, '$2') // bold/italic
|
||||
.replace(/`{1,3}[^`]*`{1,3}/g, '') // inline code
|
||||
.replace(/<[^>]+>/g, '') // HTML tags
|
||||
.replace(/\n{2,}/g, '\n') // extra newlines
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getBreadcrumb(docDir) {
|
||||
const parts = [];
|
||||
let current = docDir;
|
||||
|
||||
while (current !== DOCS_DIR) {
|
||||
const categoryFile = path.join(current, '_category_.json');
|
||||
if (fs.existsSync(categoryFile)) {
|
||||
const cat = JSON.parse(fs.readFileSync(categoryFile, 'utf-8'));
|
||||
parts.unshift(cat.label);
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
function getUrl(docPath) {
|
||||
const relPath = path.relative(DOCS_DIR, docPath);
|
||||
const withoutExt = relPath.replace(/\.md$/, '');
|
||||
if (withoutExt === 'intro') return '/';
|
||||
return '/' + withoutExt;
|
||||
}
|
||||
|
||||
function getTitle(docPath, content) {
|
||||
const fmMatch = content.match(/sidebar_label:\s*['"]?(.+?)['"]?\s*$/m);
|
||||
if (fmMatch) return fmMatch[1].trim();
|
||||
const headingMatch = content.match(/^#{1,6}\s+(.+)$/m);
|
||||
if (headingMatch) return headingMatch[1].trim();
|
||||
return path.basename(docPath, '.md');
|
||||
}
|
||||
|
||||
function tokenize(text) {
|
||||
const tokens = jieba.cut(text);
|
||||
return [...new Set(tokens.filter(t => t.length > 1 && !/^[\s\d\p{P}]+$/u.test(t)))];
|
||||
}
|
||||
|
||||
function processDoc(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const text = stripMarkdown(content);
|
||||
const title = getTitle(filePath, content);
|
||||
const breadcrumb = getBreadcrumb(path.dirname(filePath));
|
||||
const url = getUrl(filePath);
|
||||
const tokens = tokenize(text);
|
||||
|
||||
const fullBreadcrumb = breadcrumb ? `${breadcrumb} > ${title}` : title;
|
||||
|
||||
return { title, text, tokens, breadcrumb: fullBreadcrumb, url };
|
||||
}
|
||||
|
||||
function walkDocs(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const mdFiles = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.name === 'superpowers') continue;
|
||||
if (entry.isDirectory()) {
|
||||
mdFiles.push(...walkDocs(fullPath));
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
mdFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
return mdFiles;
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('Generating document index...');
|
||||
const mdFiles = walkDocs(DOCS_DIR);
|
||||
console.log(`Found ${mdFiles.length} markdown files`);
|
||||
|
||||
const index = mdFiles.map(processDoc);
|
||||
const totalTokens = index.reduce((sum, doc) => sum + doc.tokens.length, 0);
|
||||
console.log(`Index: ${index.length} docs, ${totalTokens} total unique tokens`);
|
||||
|
||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(index, null, 2), 'utf-8');
|
||||
console.log(`Written to ${OUTPUT_PATH}`);
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const STORAGE_PREFIX = 'announcement_closed_';
|
||||
|
||||
export default function AnnouncementBanner() {
|
||||
const [announcement, setAnnouncement] = useState(null);
|
||||
const [closed, setClosed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/announcement.json')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!data || !data.id || !data.text) return;
|
||||
const wasClosed = localStorage.getItem(STORAGE_PREFIX + data.id);
|
||||
if (wasClosed) return;
|
||||
setAnnouncement(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function handleClose() {
|
||||
if (announcement) {
|
||||
localStorage.setItem(STORAGE_PREFIX + announcement.id, 'true');
|
||||
}
|
||||
setClosed(true);
|
||||
}
|
||||
|
||||
if (!announcement || closed) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.banner}>
|
||||
<div className={styles.content}>
|
||||
<span className={styles.text}>{announcement.text}</span>
|
||||
{announcement.link && announcement.linkText && (
|
||||
<a href={announcement.link} className={styles.link} target="_blank" rel="noopener noreferrer">
|
||||
{announcement.linkText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<button className={styles.closeBtn} onClick={handleClose} aria-label="关闭公告">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
.banner {
|
||||
background: linear-gradient(135deg, #466aff 0%, #6b8cff 100%);
|
||||
color: #fff;
|
||||
padding: 0.6rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: #e0e7ff;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.banner {
|
||||
padding: 0.5rem 2.5rem 0.5rem 1rem;
|
||||
}
|
||||
.text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const SCROLL_THRESHOLD = 300;
|
||||
|
||||
export default function BackToTop() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleScroll() {
|
||||
setVisible(window.scrollY > SCROLL_THRESHOLD);
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${styles.btn} ${visible ? styles.visible : ''}`}
|
||||
onClick={scrollToTop}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
.btn {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #466aff;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 12px rgba(70, 106, 255, 0.4);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.3s ease;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 16px rgba(70, 106, 255, 0.6);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .btn {
|
||||
background: #466aff;
|
||||
box-shadow: 0 2px 12px rgba(70, 106, 255, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.btn {
|
||||
bottom: 1.2rem;
|
||||
right: 1.2rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useLocation } from '@docusaurus/router';
|
||||
import { useAllDocsData } from '@docusaurus/plugin-content-docs/client';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const STORAGE_KEY = 'gitlink_bookmarks';
|
||||
|
||||
function getBookmarks() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function deriveTitle(id) {
|
||||
if (!id) return '';
|
||||
const segments = id.split('/');
|
||||
return segments[segments.length - 1];
|
||||
}
|
||||
|
||||
export default function BookmarkButton() {
|
||||
const location = useLocation();
|
||||
const allDocsData = useAllDocsData();
|
||||
const [bookmarked, setBookmarked] = useState(false);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
// Check if current doc is bookmarked
|
||||
useEffect(() => {
|
||||
const stored = getBookmarks();
|
||||
setBookmarked(stored.some((d) => d.path === location.pathname));
|
||||
}, [location.pathname]);
|
||||
|
||||
const showToast = useCallback((msg) => {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), 2000);
|
||||
}, []);
|
||||
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (!allDocsData) return;
|
||||
|
||||
const pluginIds = Object.keys(allDocsData);
|
||||
if (pluginIds.length === 0) return;
|
||||
const plugin = allDocsData[pluginIds[0]];
|
||||
|
||||
let activeVersion = null;
|
||||
for (const version of plugin.versions) {
|
||||
if (!version.docs) continue;
|
||||
const docs = Object.values(version.docs);
|
||||
if (docs.find((doc) => doc.path && location.pathname.endsWith(doc.path))) {
|
||||
activeVersion = version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!activeVersion || !activeVersion.docs) return;
|
||||
|
||||
const allDocs = Object.values(activeVersion.docs);
|
||||
const currentDoc = allDocs.find(
|
||||
(doc) => doc.path && location.pathname.endsWith(doc.path)
|
||||
);
|
||||
if (!currentDoc) return;
|
||||
|
||||
const stored = getBookmarks();
|
||||
const existing = stored.findIndex((d) => d.path === currentDoc.path);
|
||||
|
||||
if (existing >= 0) {
|
||||
// Remove bookmark
|
||||
stored.splice(existing, 1);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
|
||||
setBookmarked(false);
|
||||
showToast('已取消收藏');
|
||||
} else {
|
||||
// Add bookmark
|
||||
stored.unshift({
|
||||
path: currentDoc.path,
|
||||
id: currentDoc.id,
|
||||
title: deriveTitle(currentDoc.id),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
|
||||
setBookmarked(true);
|
||||
showToast('已收藏,可在侧边栏查看');
|
||||
}
|
||||
|
||||
// Dispatch custom event so BookmarkList updates
|
||||
window.dispatchEvent(new Event('bookmark-change'));
|
||||
}, [location.pathname, allDocsData, showToast]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={`${styles.btn} ${bookmarked ? styles.active : ''}`}
|
||||
onClick={toggleBookmark}
|
||||
title={bookmarked ? '取消收藏' : '收藏本文'}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={bookmarked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||
</svg>
|
||||
{bookmarked ? '已收藏' : '收藏本文'}
|
||||
</button>
|
||||
{toast && <div className={styles.toast}>{toast}</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 6px;
|
||||
background: var(--ifm-background-color);
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--ifm-color-primary);
|
||||
color: var(--ifm-color-primary);
|
||||
background: var(--ifm-color-primary-contrast-background);
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
border-color: var(--ifm-color-primary);
|
||||
color: var(--ifm-color-primary);
|
||||
background: var(--ifm-color-primary-contrast-background);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 6px;
|
||||
background: var(--ifm-color-emphasis-700);
|
||||
color: var(--ifm-color-emphasis-100);
|
||||
font-size: 0.85rem;
|
||||
z-index: var(--ifm-z-index-fixed);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
animation: fadeInOut 2s ease forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0% { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
15% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
85% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
100% { opacity: 0; transform: translateX(-50%) translateY(-8px); }
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const STORAGE_KEY = 'gitlink_bookmarks';
|
||||
|
||||
function getBookmarks() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default function BookmarkList() {
|
||||
const [bookmarks, setBookmarks] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setBookmarks(getBookmarks());
|
||||
|
||||
const handler = () => setBookmarks(getBookmarks());
|
||||
window.addEventListener('bookmark-change', handler);
|
||||
return () => window.removeEventListener('bookmark-change', handler);
|
||||
}, []);
|
||||
|
||||
if (bookmarks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.heading}>收藏</div>
|
||||
<ul className={styles.list}>
|
||||
{bookmarks.slice(0, 10).map((bm) => (
|
||||
<li key={bm.path} className={styles.item}>
|
||||
<Link to={bm.path} className={styles.link}>
|
||||
<svg className={styles.starIcon} width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||
</svg>
|
||||
<span className={styles.title}>{bm.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
.container {
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--ifm-color-emphasis-500);
|
||||
margin-bottom: 0.35rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none !important;
|
||||
color: var(--ifm-font-color-base);
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
background: var(--ifm-menu-color-background-hover);
|
||||
color: var(--ifm-menu-color-active);
|
||||
}
|
||||
|
||||
.starIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--ifm-color-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useLocation } from '@docusaurus/router';
|
||||
import { useAllDocsData } from '@docusaurus/plugin-content-docs/client';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
function deriveSegments(id) {
|
||||
if (!id) return [];
|
||||
return id.split('/');
|
||||
}
|
||||
|
||||
function deriveTitle(id) {
|
||||
if (!id) return '';
|
||||
const segments = id.split('/');
|
||||
return segments[segments.length - 1];
|
||||
}
|
||||
|
||||
function deriveSourceDir(id) {
|
||||
if (!id) return '';
|
||||
const segments = id.split('/');
|
||||
if (segments.length <= 1) return '';
|
||||
return segments.slice(0, -1).join('/');
|
||||
}
|
||||
|
||||
export default function Breadcrumb() {
|
||||
const location = useLocation();
|
||||
const allDocsData = useAllDocsData();
|
||||
const [items, setItems] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allDocsData) return;
|
||||
|
||||
const pluginIds = Object.keys(allDocsData);
|
||||
if (pluginIds.length === 0) return;
|
||||
|
||||
const plugin = allDocsData[pluginIds[0]];
|
||||
|
||||
// Find active version
|
||||
let activeVersion = null;
|
||||
for (const version of plugin.versions) {
|
||||
if (!version.docs) continue;
|
||||
const docs = Object.values(version.docs);
|
||||
const found = docs.find(
|
||||
(doc) => doc.path && location.pathname.endsWith(doc.path)
|
||||
);
|
||||
if (found) {
|
||||
activeVersion = version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeVersion) {
|
||||
activeVersion = plugin.versions.find((v) => v.isLast) || plugin.versions[0];
|
||||
}
|
||||
|
||||
if (!activeVersion || !activeVersion.docs) return;
|
||||
|
||||
const allDocs = Object.values(activeVersion.docs);
|
||||
const currentDoc = allDocs.find(
|
||||
(doc) => doc.path && location.pathname.endsWith(doc.path)
|
||||
);
|
||||
if (!currentDoc) return;
|
||||
|
||||
const segments = deriveSegments(currentDoc.id);
|
||||
// Only show breadcrumbs for docs with depth >= 2 (i.e., not root-level)
|
||||
if (segments.length <= 1) return;
|
||||
|
||||
const breadcrumbItems = [];
|
||||
|
||||
// Home
|
||||
breadcrumbItems.push({ label: '帮助中心', href: '/' });
|
||||
|
||||
// Intermediate segments (category levels)
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const prefix = segments.slice(0, i + 1).join('/');
|
||||
// Find first doc in this category for the link
|
||||
const categoryDoc = allDocs.find(
|
||||
(doc) => deriveSourceDir(doc.id) === prefix
|
||||
);
|
||||
breadcrumbItems.push({
|
||||
label: segments[i],
|
||||
href: categoryDoc ? categoryDoc.path : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Current doc (last segment, not linked)
|
||||
breadcrumbItems.push({ label: segments[segments.length - 1] });
|
||||
|
||||
setItems(breadcrumbItems);
|
||||
}, [location.pathname, allDocsData]);
|
||||
|
||||
if (!items || items.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<nav className={styles.breadcrumbs} aria-label="面包屑导航">
|
||||
{items.map((item, idx) => (
|
||||
<span key={idx} className={styles.item}>
|
||||
{idx > 0 && <span className={styles.separator}>›</span>}
|
||||
{item.href && idx < items.length - 1 ? (
|
||||
<Link to={item.href} className={styles.link}>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={idx === items.length - 1 ? styles.current : styles.label}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 0.35rem;
|
||||
color: var(--ifm-color-emphasis-400);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--ifm-color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
.current {
|
||||
color: var(--ifm-color-emphasis-900);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export default function ChatButton({ isOpen, onClick }) {
|
||||
return (
|
||||
<button
|
||||
className={`${styles.btn} ${isOpen ? styles.btnOpen : ''}`}
|
||||
onClick={onClick}
|
||||
aria-label={isOpen ? '关闭 AI 助手' : '打开 AI 助手'}
|
||||
>
|
||||
{isOpen ? (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
.btn {
|
||||
position: fixed;
|
||||
bottom: 5.5rem;
|
||||
right: 2rem;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #466aff;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 16px rgba(70, 106, 255, 0.4);
|
||||
z-index: 1000;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 6px 24px rgba(70, 106, 255, 0.5);
|
||||
}
|
||||
|
||||
.btnOpen {
|
||||
background: #666;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btnOpen:hover {
|
||||
background: #555;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .btn {
|
||||
background: #466aff;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .btnOpen {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.btn {
|
||||
bottom: 4.5rem;
|
||||
right: 1.2rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import ChatMessage from '../ChatMessage';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const WELCOME_MESSAGE = {
|
||||
role: 'assistant',
|
||||
content: '你好!我是 GitLink 帮助中心 AI 助手。\n\n你可以问我关于 GitLink 平台使用的问题,比如如何创建仓库、管理合并请求、配置 CI/CD 等。',
|
||||
sources: [],
|
||||
};
|
||||
|
||||
export default function ChatDrawer({ isOpen, onClose }) {
|
||||
const [messages, setMessages] = useState([WELCOME_MESSAGE]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [streamingIndex, setStreamingIndex] = useState(-1);
|
||||
const messagesEndRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.classList.add('chat-drawer-open');
|
||||
setTimeout(() => inputRef.current?.focus(), 300);
|
||||
} else {
|
||||
document.body.classList.remove('chat-drawer-open');
|
||||
}
|
||||
return () => document.body.classList.remove('chat-drawer-open');
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function sendMessage() {
|
||||
const text = input.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
const userMessage = { role: 'user', content: text };
|
||||
const newMessages = [...messages, userMessage];
|
||||
setMessages(newMessages);
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
setStreamingIndex(newMessages.length);
|
||||
|
||||
const history = newMessages.slice(1, -1).map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const assistantMessage = { role: 'assistant', content: '', sources: [] };
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text, history }),
|
||||
signal: abortRef.current?.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
||||
try {
|
||||
const data = JSON.parse(trimmed.slice(6));
|
||||
|
||||
if (data.type === 'content') {
|
||||
assistantMessage.content += data.text;
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = { ...assistantMessage };
|
||||
return updated;
|
||||
});
|
||||
} else if (data.type === 'sources') {
|
||||
assistantMessage.sources = data.links;
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = { ...assistantMessage };
|
||||
return updated;
|
||||
});
|
||||
} else if (data.type === 'error') {
|
||||
assistantMessage.content += `\n\n⚠️ ${data.message}`;
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = { ...assistantMessage };
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
assistantMessage.content = '抱歉,发生了错误,请稍后重试。';
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = { ...assistantMessage };
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setStreamingIndex(-1);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.overlay} ${isOpen ? styles.overlayOpen : ''}`}>
|
||||
<div className={styles.header}>
|
||||
<span>GitLink AI 助手</span>
|
||||
<button className={styles.closeBtn} onClick={onClose} aria-label="关闭">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.messages}>
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage
|
||||
key={i}
|
||||
message={msg}
|
||||
isStreaming={i === streamingIndex}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className={styles.inputArea}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入你的问题..."
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
className={styles.sendBtn}
|
||||
onClick={sendMessage}
|
||||
disabled={isLoading || !input.trim()}
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 400px;
|
||||
background: #fff;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.overlayOpen {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .overlay {
|
||||
background: #1b1b1b;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .header {
|
||||
border-bottom-color: #333;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .inputArea {
|
||||
border-top-color: #333;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
min-height: 40px;
|
||||
max-height: 120px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #466aff;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .input {
|
||||
background: #2a2a2a;
|
||||
border-color: #444;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .input:focus {
|
||||
border-color: #466aff;
|
||||
}
|
||||
|
||||
.sendBtn {
|
||||
background: #466aff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.sendBtn:hover {
|
||||
background: #3558e0;
|
||||
}
|
||||
|
||||
.sendBtn:disabled {
|
||||
background: #aaa;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.overlay {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export default function ChatMessage({ message, isStreaming }) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
return (
|
||||
<div className={`${styles.message} ${isUser ? styles.messageUser : styles.messageAssistant}`}>
|
||||
<div className={`${styles.bubble} ${isUser ? styles.bubbleUser : styles.bubbleAssistant}`}>
|
||||
{message.content}
|
||||
{!isUser && isStreaming && <span className={styles.cursor} />}
|
||||
</div>
|
||||
{!isUser && message.sources && message.sources.length > 0 && (
|
||||
<div className={styles.sources}>
|
||||
<div className={styles.sourcesTitle}>📎 相关文档</div>
|
||||
{message.sources.map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
className={styles.sourceLink}
|
||||
href={link.path}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{link.breadcrumb}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
.message {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messageUser {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.messageAssistant {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 85%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.bubbleUser {
|
||||
background: #e8e8e8;
|
||||
color: #333;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.bubbleAssistant {
|
||||
background: #f0f4ff;
|
||||
color: #333;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .bubbleUser {
|
||||
background: #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .bubbleAssistant {
|
||||
background: #2a2e4a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.sources {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
max-width: 85%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sources {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.sourcesTitle {
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sourcesTitle {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.sourceLink {
|
||||
display: block;
|
||||
color: #466aff;
|
||||
text-decoration: none;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.sourceLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: #466aff;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation } from '@docusaurus/router';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const STORAGE_PREFIX = 'doc_feedback_';
|
||||
|
||||
async function submitFeedback(docPath, vote, suggestion) {
|
||||
try {
|
||||
await fetch('/api/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ docPath, vote, suggestion }),
|
||||
});
|
||||
} catch {
|
||||
// silently fail — data stays in localStorage as fallback
|
||||
}
|
||||
}
|
||||
|
||||
export default function DocFeedback() {
|
||||
const location = useLocation();
|
||||
const [voted, setVoted] = useState(null);
|
||||
const [showSuggestion, setShowSuggestion] = useState(false);
|
||||
const [suggestion, setSuggestion] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_PREFIX + location.pathname);
|
||||
if (stored) {
|
||||
setVoted(stored);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
function handleVote(type) {
|
||||
if (voted) return;
|
||||
localStorage.setItem(STORAGE_PREFIX + location.pathname, type);
|
||||
setVoted(type);
|
||||
submitFeedback(location.pathname, type);
|
||||
if (type === 'no') {
|
||||
setShowSuggestion(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmitSuggestion() {
|
||||
const key = STORAGE_PREFIX + 'suggestion_' + location.pathname;
|
||||
localStorage.setItem(key, suggestion);
|
||||
submitFeedback(location.pathname, 'no', suggestion);
|
||||
setShowSuggestion(false);
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
function handleSkip() {
|
||||
setShowSuggestion(false);
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<p className={styles.question}>这篇文档对您有帮助吗?</p>
|
||||
{voted === 'yes' && (
|
||||
<p className={styles.thanks}>感谢您的反馈!</p>
|
||||
)}
|
||||
{voted === 'no' && showSuggestion && !submitted && (
|
||||
<div className={styles.suggestionBox}>
|
||||
<p className={styles.suggestionTitle}>请告诉我们如何改进</p>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
rows={3}
|
||||
placeholder="请输入您的改进建议(选填)"
|
||||
value={suggestion}
|
||||
onChange={(e) => setSuggestion(e.target.value)}
|
||||
/>
|
||||
<div className={styles.suggestionActions}>
|
||||
<button className={styles.submitBtn} onClick={handleSubmitSuggestion}>
|
||||
提交建议
|
||||
</button>
|
||||
<button className={styles.skipBtn} onClick={handleSkip}>
|
||||
跳过
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{voted === 'no' && !showSuggestion && submitted && (
|
||||
<p className={styles.thanks}>感谢您的反馈!</p>
|
||||
)}
|
||||
{!voted && (
|
||||
<div className={styles.buttons}>
|
||||
<button
|
||||
className={styles.btn}
|
||||
onClick={() => handleVote('yes')}
|
||||
>
|
||||
有帮助
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.btn} ${styles.btnNo}`}
|
||||
onClick={() => handleVote('no')}
|
||||
>
|
||||
没帮助
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
.container {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
padding-bottom: 3rem;
|
||||
border-top: 1px solid var(--ifm-color-emphasis-300);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.question {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ifm-font-color-base);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #466aff;
|
||||
background: transparent;
|
||||
color: #466aff;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #466aff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btnNo {
|
||||
border-color: var(--ifm-color-emphasis-400);
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
.btnNo:hover {
|
||||
background: var(--ifm-color-emphasis-400);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.thanks {
|
||||
color: #466aff;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.suggestionBox {
|
||||
margin-top: 1rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--ifm-background-surface-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.suggestionTitle {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--ifm-font-color-base);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
background: var(--ifm-background-color);
|
||||
color: var(--ifm-font-color-base);
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: #466aff;
|
||||
box-shadow: 0 0 0 2px rgba(70, 106, 255, 0.15);
|
||||
}
|
||||
|
||||
.textarea::placeholder {
|
||||
color: var(--ifm-color-emphasis-500);
|
||||
}
|
||||
|
||||
.suggestionActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
padding: 0.4rem 1.2rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: #466aff;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.submitBtn:hover {
|
||||
background: #3558e0;
|
||||
}
|
||||
|
||||
.skipBtn {
|
||||
padding: 0.4rem 1.2rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
background: transparent;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.skipBtn:hover {
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.suggestionBox {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useLocation } from '@docusaurus/router';
|
||||
import { useAllDocsData } from '@docusaurus/plugin-content-docs/client';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const STORAGE_KEY = 'gitlink_recent_docs';
|
||||
const MAX_ITEMS = 10;
|
||||
|
||||
function deriveTitle(id) {
|
||||
if (!id) return '';
|
||||
const segments = id.split('/');
|
||||
return segments[segments.length - 1];
|
||||
}
|
||||
|
||||
function getStored() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(timestamp) {
|
||||
const diff = Date.now() - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return '刚刚';
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} 天前`;
|
||||
}
|
||||
|
||||
export default function RecentDocs() {
|
||||
const location = useLocation();
|
||||
const allDocsData = useAllDocsData();
|
||||
const [items, setItems] = useState([]);
|
||||
|
||||
// Track current page visit
|
||||
useEffect(() => {
|
||||
if (!allDocsData) return;
|
||||
|
||||
const pluginIds = Object.keys(allDocsData);
|
||||
if (pluginIds.length === 0) return;
|
||||
|
||||
const plugin = allDocsData[pluginIds[0]];
|
||||
|
||||
let activeVersion = null;
|
||||
for (const version of plugin.versions) {
|
||||
if (!version.docs) continue;
|
||||
const docs = Object.values(version.docs);
|
||||
const found = docs.find(
|
||||
(doc) => doc.path && location.pathname.endsWith(doc.path)
|
||||
);
|
||||
if (found) {
|
||||
activeVersion = version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeVersion || !activeVersion.docs) return;
|
||||
|
||||
const allDocs = Object.values(activeVersion.docs);
|
||||
const currentDoc = allDocs.find(
|
||||
(doc) => doc.path && location.pathname.endsWith(doc.path)
|
||||
);
|
||||
if (!currentDoc) return;
|
||||
|
||||
const title = deriveTitle(currentDoc.id);
|
||||
const entry = {
|
||||
path: currentDoc.path,
|
||||
id: currentDoc.id,
|
||||
title,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const stored = getStored();
|
||||
const filtered = stored.filter((d) => d.path !== entry.path);
|
||||
filtered.unshift(entry);
|
||||
if (filtered.length > MAX_ITEMS) filtered.pop();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||
|
||||
setItems(filtered);
|
||||
}, [location.pathname, allDocsData]);
|
||||
|
||||
// Read-only display (no tracking)
|
||||
const displayItems = items.length > 0 ? items : getStored();
|
||||
|
||||
// Don't show on homepage (intro) or empty state
|
||||
if (displayItems.length < 2) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h4 className={styles.heading}>最近浏览</h4>
|
||||
<ul className={styles.list}>
|
||||
{displayItems.map((item) => (
|
||||
<li key={item.path} className={styles.item}>
|
||||
<Link to={item.path} className={styles.link}>
|
||||
<span className={styles.title}>{item.title}</span>
|
||||
<span className={styles.time}>{timeAgo(item.timestamp)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
.container {
|
||||
/* Separator is handled by parent .recentSection */
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--ifm-color-emphasis-500);
|
||||
margin-bottom: 0.35rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none !important;
|
||||
color: var(--ifm-font-color-base);
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
background: var(--ifm-menu-color-background-hover);
|
||||
color: var(--ifm-menu-color-active);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.68rem;
|
||||
color: var(--ifm-color-emphasis-400);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link:hover .time {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
|
@ -148,4 +148,13 @@ html[data-theme='dark'] .docusaurus-highlight-code-line {
|
|||
.widget{
|
||||
display: none!important;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Hide TOC when chat drawer is open */
|
||||
body.chat-drawer-open .table-of-contents {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.chat-drawer-open {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
import React from 'react';
|
||||
import Layout from '@theme/Layout';
|
||||
import Link from '@docusaurus/Link';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import NewsSection from '../components/NewsSection';
|
||||
import HeroSection from '../components/HeroSection';
|
||||
import ProductsSection from '../components/ProductsSection';
|
||||
import InvestorsSection from '../components/InvestorsSection';
|
||||
import CustomersSection from '../components/CustomersSection';
|
||||
import DeveloperCommunitySection from '../components/DeveloperCommunitySection';
|
||||
import FeatureSection from '../components/FeatureSection';
|
||||
|
||||
const Icon3 = require('../../static/img/home-buildingblocks.svg').default;
|
||||
|
||||
const title = 'BoxyHQ';
|
||||
const description = 'Security Building Blocks for Developers';
|
||||
|
||||
const IndexPage = () => {
|
||||
return (
|
||||
<Layout title={title} description={description}>
|
||||
<HeroSection
|
||||
title="Security Building Blocks for Developers"
|
||||
description="Reduce Time to Market without sacrificing your security posture! BoxyHQ’s suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster."
|
||||
image="/img/home-hero.svg"
|
||||
buttons={[
|
||||
{
|
||||
title: 'Get Started',
|
||||
href: 'https://boxyhq.com/docs',
|
||||
className: 'button--primary',
|
||||
},
|
||||
{
|
||||
title: 'Book a demo',
|
||||
href: 'https://meetings.hubspot.com/deepakprab/demo?__hstc=213510283.9a563789bb583fca1e9fb20a629c5c94.1651597904536.1664893584285.1664895920328.169&__hssc=213510283.1.1664895920328&__hsfp=498882655',
|
||||
className: 'button--primary button--outline',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* <CustomersSection />
|
||||
<ProductsSection /> */}
|
||||
|
||||
<section className="page__section">
|
||||
<div className="container">
|
||||
<div className="row" style={{ gap: '10px', marginTop: '20px' }}>
|
||||
<div className="col col--5 col--offset-1">
|
||||
<div className="col-demo">
|
||||
<div className="card-demo" style={{}}>
|
||||
<div className="card">
|
||||
<div className="card__header">
|
||||
<h3
|
||||
className="text--center"
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
>
|
||||
The Importance of Developer Security
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card__body">
|
||||
<p className="text--center">
|
||||
Cyber crimes are predicted to cost $10.5 trillion
|
||||
annually by 2025, but time pressures often cause 70% of
|
||||
development teams to skip crucial security steps.
|
||||
</p>
|
||||
<p>
|
||||
In today's world, it is no longer sufficient to
|
||||
prioritize productivity alone. The faster a developer
|
||||
moves, the more potential security holes will be left
|
||||
unaddressed.{' '}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card__footer">
|
||||
<Link
|
||||
className={clsx('button button--primary button--block')}
|
||||
href="https://meetings.hubspot.com/deepakprab/demo?__hstc=213510283.9a563789bb583fca1e9fb20a629c5c94.1651597904536.1664893584285.1664895920328.169&__hssc=213510283.1.1664895920328&__hsfp=498882655"
|
||||
>
|
||||
Book a free developer-security session
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col col--5">
|
||||
<div className="col-demo">
|
||||
<div className="card-demo">
|
||||
<div className="card">
|
||||
<div className="card__header">
|
||||
<h3
|
||||
className="text--center"
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
>
|
||||
Build or buy?
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card__body">
|
||||
<p className="text--center">
|
||||
Save time and money with BoxyHQ's free open-source
|
||||
solution. Available under an Apache 2.0 license, our
|
||||
solutions make it easy for developers to collaborate and
|
||||
innovate, without the need for custom building or
|
||||
expensive fees.
|
||||
</p>
|
||||
<p className="text--center">
|
||||
Our solutions run in your environment, giving you full
|
||||
control. We simply provide the building blocks to help
|
||||
you succeed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="card__footer">
|
||||
<Link
|
||||
className={clsx('button button--primary button--block')}
|
||||
href="mailto:hello@boxyhq.com"
|
||||
>
|
||||
Contact Us
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FeatureSection
|
||||
title="Developer Security Tools"
|
||||
btnLink="https://awesome-oss-devsec.boxyhq.com/"
|
||||
btnText="Read More"
|
||||
image={Icon3}
|
||||
direction="right"
|
||||
>
|
||||
<p>
|
||||
We have curated a list of awesome open-source developer security
|
||||
tools.
|
||||
</p>
|
||||
<p>
|
||||
It includes security principles and controls relevant to popular
|
||||
compliance certifications (like ISO27001, SOC2, MVSP, etc.)
|
||||
</p>
|
||||
</FeatureSection>
|
||||
|
||||
{/* <NewsSection />
|
||||
<InvestorsSection />
|
||||
<DeveloperCommunitySection /> */}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexPage;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import React, {type ReactNode} from 'react';
|
||||
import Footer from '@theme-original/DocItem/Footer';
|
||||
import type FooterType from '@theme/DocItem/Footer';
|
||||
import type {WrapperProps} from '@docusaurus/types';
|
||||
import DocFeedback from '../../../components/DocFeedback';
|
||||
import BookmarkButton from '../../../components/BookmarkButton';
|
||||
|
||||
type Props = WrapperProps<typeof FooterType>;
|
||||
|
||||
export default function FooterWrapper(props: Props): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<Footer {...props} />
|
||||
<div style={{marginTop: '0.5rem'}}>
|
||||
<BookmarkButton />
|
||||
</div>
|
||||
<DocFeedback />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import React, {type ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useWindowSize} from '@docusaurus/theme-common';
|
||||
import {useDoc} from '@docusaurus/plugin-content-docs/client';
|
||||
import DocItemPaginator from '@theme/DocItem/Paginator';
|
||||
import DocVersionBanner from '@theme/DocVersionBanner';
|
||||
import DocVersionBadge from '@theme/DocVersionBadge';
|
||||
import DocItemFooter from '@theme/DocItem/Footer';
|
||||
import DocItemTOCMobile from '@theme/DocItem/TOC/Mobile';
|
||||
import DocItemTOCDesktop from '@theme/DocItem/TOC/Desktop';
|
||||
import DocItemContent from '@theme/DocItem/Content';
|
||||
import ContentVisibility from '@theme/ContentVisibility';
|
||||
import Breadcrumb from '../../../components/Breadcrumb';
|
||||
import type {Props} from '@theme/DocItem/Layout';
|
||||
|
||||
import styles from './styles.module.css';
|
||||
|
||||
/**
|
||||
* Decide if the toc should be rendered, on mobile or desktop viewports
|
||||
*/
|
||||
function useDocTOC() {
|
||||
const {frontMatter, toc} = useDoc();
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
const hidden = frontMatter.hide_table_of_contents;
|
||||
const canRender = !hidden && toc.length > 0;
|
||||
|
||||
const mobile = canRender ? <DocItemTOCMobile /> : undefined;
|
||||
|
||||
const desktop =
|
||||
canRender && (windowSize === 'desktop' || windowSize === 'ssr') ? (
|
||||
<DocItemTOCDesktop />
|
||||
) : undefined;
|
||||
|
||||
return {
|
||||
hidden,
|
||||
mobile,
|
||||
desktop,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DocItemLayout({children}: Props): ReactNode {
|
||||
const docTOC = useDocTOC();
|
||||
const {metadata} = useDoc();
|
||||
return (
|
||||
<div className="row">
|
||||
<div className={clsx('col', !docTOC.hidden && styles.docItemCol)}>
|
||||
<ContentVisibility metadata={metadata} />
|
||||
<DocVersionBanner />
|
||||
<div className={styles.docItemContainer}>
|
||||
<article>
|
||||
<Breadcrumb />
|
||||
<DocVersionBadge />
|
||||
{docTOC.mobile}
|
||||
<DocItemContent>{children}</DocItemContent>
|
||||
<DocItemFooter />
|
||||
</article>
|
||||
<DocItemPaginator />
|
||||
</div>
|
||||
</div>
|
||||
{docTOC.desktop && <div className="col col--3">{docTOC.desktop}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
.docItemContainer header + *,
|
||||
.docItemContainer article > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 997px) {
|
||||
.docItemCol {
|
||||
max-width: 75% !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useThemeConfig} from '@docusaurus/theme-common';
|
||||
import Logo from '@theme/Logo';
|
||||
import CollapseButton from '@theme/DocSidebar/Desktop/CollapseButton';
|
||||
import Content from '@theme/DocSidebar/Desktop/Content';
|
||||
import type {Props} from '@theme/DocSidebar/Desktop';
|
||||
import RecentDocs from '../../../components/RecentDocs';
|
||||
import BookmarkList from '../../../components/BookmarkList';
|
||||
|
||||
import styles from './styles.module.css';
|
||||
|
||||
function DocSidebarDesktop({path, sidebar, onCollapse, isHidden}: Props) {
|
||||
const {
|
||||
navbar: {hideOnScroll},
|
||||
docs: {
|
||||
sidebar: {hideable},
|
||||
},
|
||||
} = useThemeConfig();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.sidebar,
|
||||
hideOnScroll && styles.sidebarWithHideableNavbar,
|
||||
isHidden && styles.sidebarHidden,
|
||||
)}>
|
||||
{hideOnScroll && <Logo tabIndex={-1} className={styles.sidebarLogo} />}
|
||||
<div className={styles.recentSection}>
|
||||
<RecentDocs />
|
||||
<BookmarkList />
|
||||
</div>
|
||||
<Content path={path} sidebar={sidebar} />
|
||||
{hideable && <CollapseButton onClick={onCollapse} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(DocSidebarDesktop);
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
@media (min-width: 997px) {
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding-top: var(--ifm-navbar-height);
|
||||
width: var(--doc-sidebar-width);
|
||||
}
|
||||
|
||||
.sidebarWithHideableNavbar {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.sidebarHidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.sidebarLogo {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
margin: 0 var(--ifm-navbar-padding-horizontal);
|
||||
min-height: var(--ifm-navbar-height);
|
||||
max-height: var(--ifm-navbar-height);
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.sidebarLogo img {
|
||||
margin-right: 0.5rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebarLogo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.recentSection {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
padding: 0.5rem 0.5rem 0.35rem;
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import React, { useState } from 'react';
|
||||
import Layout from '@theme-original/Layout';
|
||||
import BackToTop from '../../components/BackToTop';
|
||||
import AnnouncementBanner from '../../components/AnnouncementBanner';
|
||||
import ChatButton from '../../components/ChatButton';
|
||||
import ChatDrawer from '../../components/ChatDrawer';
|
||||
|
||||
export default function LayoutWrapper(props) {
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnnouncementBanner />
|
||||
<Layout {...props} />
|
||||
<BackToTop />
|
||||
<ChatButton isOpen={chatOpen} onClick={() => setChatOpen(!chatOpen)} />
|
||||
<ChatDrawer isOpen={chatOpen} onClose={() => setChatOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>反馈统计 - GitLink 帮助中心</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
|
||||
|
||||
.header { background: #1b2440; color: #fff; padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between; }
|
||||
.header h1 { font-size: 1.2rem; font-weight: 600; }
|
||||
.header a { color: #8b9fd4; text-decoration: none; font-size: 0.85rem; }
|
||||
.header a:hover { color: #fff; }
|
||||
|
||||
.login-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.login-box { background: #fff; padding: 2rem; border-radius: 12px; width: 360px; box-shadow: 0 8px 32px rgba(0,0,0,0.15); }
|
||||
.login-box h2 { margin-bottom: 1rem; font-size: 1.1rem; }
|
||||
.login-box input { width: 100%; padding: 0.6rem 0.8rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.login-box button { width: 100%; padding: 0.6rem; background: #466aff; color: #fff; border: none; border-radius: 6px; font-size: 0.9rem; cursor: pointer; }
|
||||
.login-box button:hover { background: #3558e0; }
|
||||
.login-error { color: #e74c3c; font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
||||
|
||||
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.stat-card { background: #fff; border-radius: 10px; padding: 1.25rem; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||
.stat-card .label { font-size: 0.8rem; color: #888; margin-bottom: 0.3rem; }
|
||||
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: #1b2440; }
|
||||
.stat-card .value.green { color: #27ae60; }
|
||||
.stat-card .value.red { color: #e74c3c; }
|
||||
.stat-card .value.blue { color: #466aff; }
|
||||
|
||||
.section { background: #fff; border-radius: 10px; padding: 1.25rem; box-shadow: 0 1px 4px rgba(0,0,0,0.06); margin-bottom: 1.5rem; }
|
||||
.section h2 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid #eee; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
th { text-align: left; padding: 0.6rem 0.5rem; border-bottom: 2px solid #eee; color: #888; font-weight: 600; font-size: 0.8rem; }
|
||||
td { padding: 0.6rem 0.5rem; border-bottom: 1px solid #f0f0f0; }
|
||||
tr:hover td { background: #f8f9fb; }
|
||||
|
||||
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 10px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge-yes { background: #e8f8ef; color: #27ae60; }
|
||||
.badge-no { background: #fde8e8; color: #e74c3c; }
|
||||
|
||||
.bar { height: 8px; border-radius: 4px; background: #eee; overflow: hidden; min-width: 60px; }
|
||||
.bar-fill { height: 100%; border-radius: 4px; background: #466aff; }
|
||||
|
||||
.suggestion-text { background: #f8f9fb; padding: 0.4rem 0.6rem; border-radius: 4px; font-size: 0.82rem; color: #555; margin-top: 0.2rem; }
|
||||
.time { color: #aaa; font-size: 0.8rem; }
|
||||
|
||||
.tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.tab { padding: 0.4rem 1rem; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer; font-size: 0.85rem; }
|
||||
.tab.active { background: #466aff; color: #fff; border-color: #466aff; }
|
||||
|
||||
.empty { text-align: center; padding: 2rem; color: #aaa; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||
.container { padding: 1rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>GitLink 帮助中心 — 反馈统计</h1>
|
||||
<a href="/">← 返回帮助中心</a>
|
||||
</div>
|
||||
|
||||
<div id="loginOverlay" class="login-overlay">
|
||||
<div class="login-box">
|
||||
<h2>管理员登录</h2>
|
||||
<div id="loginError" class="login-error" style="display:none"></div>
|
||||
<input type="password" id="tokenInput" placeholder="请输入管理口令" onkeydown="if(event.key==='Enter')doLogin()">
|
||||
<button onclick="doLogin()">登 录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container" id="mainContent" style="display:none">
|
||||
<div class="stats-row" id="statsRow"></div>
|
||||
|
||||
<div class="section">
|
||||
<h2>页面反馈分布</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>页面路径</th><th>有帮助</th><th>没帮助</th><th>帮助率</th><th>建议</th></tr>
|
||||
</thead>
|
||||
<tbody id="byPathBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab('all',this)">全部</div>
|
||||
<div class="tab" onclick="switchTab('suggestion',this)">仅含建议</div>
|
||||
</div>
|
||||
<h2>最近反馈</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>时间</th><th>页面</th><th>评价</th><th>建议内容</th></tr>
|
||||
</thead>
|
||||
<tbody id="recentBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let TOKEN = '';
|
||||
let currentFilter = 'all';
|
||||
let recentData = [];
|
||||
|
||||
function doLogin() {
|
||||
TOKEN = document.getElementById('tokenInput').value.trim();
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [statsRes, recentRes] = await Promise.all([
|
||||
fetch('/api/feedback/stats?token=' + TOKEN),
|
||||
fetch('/api/feedback/recent?token=' + TOKEN + '&limit=100'),
|
||||
]);
|
||||
if (!statsRes.ok || !recentRes.ok) {
|
||||
document.getElementById('loginError').style.display = 'block';
|
||||
document.getElementById('loginError').textContent = '口令错误,请重试';
|
||||
return;
|
||||
}
|
||||
document.getElementById('loginOverlay').style.display = 'none';
|
||||
document.getElementById('mainContent').style.display = 'block';
|
||||
|
||||
const stats = await statsRes.json();
|
||||
recentData = await recentRes.json();
|
||||
|
||||
renderStats(stats);
|
||||
renderByPath(stats.byPath);
|
||||
renderRecent();
|
||||
} catch (e) {
|
||||
document.getElementById('loginError').style.display = 'block';
|
||||
document.getElementById('loginError').textContent = '网络错误: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(s) {
|
||||
document.getElementById('statsRow').innerHTML = `
|
||||
<div class="stat-card"><div class="label">总反馈数</div><div class="value">${s.total}</div></div>
|
||||
<div class="stat-card"><div class="label">有帮助</div><div class="value green">${s.yes}</div></div>
|
||||
<div class="stat-card"><div class="label">没帮助</div><div class="value red">${s.no}</div></div>
|
||||
<div class="stat-card"><div class="label">帮助率</div><div class="value blue">${s.helpfulRate}%</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderByPath(paths) {
|
||||
const tbody = document.getElementById('byPathBody');
|
||||
if (!paths.length) { tbody.innerHTML = '<tr><td colspan="5" class="empty">暂无数据</td></tr>'; return; }
|
||||
tbody.innerHTML = paths.slice(0, 30).map(p => {
|
||||
const total = p.yes + p.no;
|
||||
const rate = total > 0 ? Math.round((p.yes / total) * 100) : 0;
|
||||
return `<tr>
|
||||
<td><a href="${p.path}" target="_blank">${p.path}</a></td>
|
||||
<td>${p.yes}</td><td>${p.no}</td>
|
||||
<td><div class="bar"><div class="bar-fill" style="width:${rate}%"></div></div> ${rate}%</td>
|
||||
<td>${p.suggestions.length > 0 ? p.suggestions.length + ' 条' : '-'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderRecent() {
|
||||
const tbody = document.getElementById('recentBody');
|
||||
let data = recentData;
|
||||
if (currentFilter === 'suggestion') {
|
||||
data = data.filter(e => e.suggestion);
|
||||
}
|
||||
if (!data.length) { tbody.innerHTML = '<tr><td colspan="4" class="empty">暂无数据</td></tr>'; return; }
|
||||
tbody.innerHTML = data.map(e => `<tr>
|
||||
<td class="time">${new Date(e.timestamp).toLocaleString('zh-CN')}</td>
|
||||
<td><a href="${e.docPath}" target="_blank">${e.docPath}</a></td>
|
||||
<td><span class="badge ${e.vote === 'yes' ? 'badge-yes' : 'badge-no'}">${e.vote === 'yes' ? '有帮助' : '没帮助'}</span></td>
|
||||
<td>${e.suggestion ? '<div class="suggestion-text">' + escHtml(e.suggestion) + '</div>' : '-'}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function switchTab(filter, el) {
|
||||
currentFilter = filter;
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
renderRecent();
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"id": "announcement-v1",
|
||||
"text": "GitLink 帮助中心已全新改版,为您提供更好的使用体验!",
|
||||
"link": "https://help.gitlink.org.cn/",
|
||||
"linkText": "了解更多"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
|
@ -1,195 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="420px" height="842px" viewBox="0 0 420 842" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="background: #FFFFFF;">
|
||||
<!-- Generator: Sketch 40.3 (33839) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>Artboard 4</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Artboard-4">
|
||||
<g id="HtmlToSvg" transform="translate(-5.000000, -5.000000)">
|
||||
<g id="Created-by-HiQPdf">
|
||||
<g id="Group">
|
||||
<g id="second-one" transform="translate(24.000000, 363.000000)">
|
||||
<g id="Group-3">
|
||||
<path d="M381,0 C382.657,0 384,1.343 384,3 L384,212 C384,213.657 382.657,215 381,215 L3,215 C1.343,215 0,213.657 0,212 L0,3 C0,1.343 1.343,0 3,0 L381,0 Z" id="Shape" fill="#F5F5F5"></path>
|
||||
<g id="Group" transform="translate(12.000000, 121.000000)" fill="#FFFFFF">
|
||||
<path d="M3,0 L357,0 C358.657,0 360,1.343 360,3 L360,34 C360,35.657 358.657,37 357,37 L3,37 C1.343,37 0,35.657 0,34 L0,3 C0,1.343 1.343,0 3,0" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(20.000000, 47.000000)" fill="#395185">
|
||||
<path d="M19.8409684,21.0000692 C20.4809758,21.0000692 20.9999872,20.4811399 20.9999872,19.8410504 L20.9999872,1.15901882 C20.9999872,0.51884734 20.4809758,0 19.8409684,0 L1.15901882,0 C0.518765309,0 0,0.51884734 0,1.15901882 L0,19.8410504 C0,20.4811399 0.518765309,21.0000692 1.15901882,21.0000692 L19.8409684,21.0000692" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(28.449214, 50.117186)" fill="#FFFFFF">
|
||||
<path d="M6.04036741,17.8828836 L6.04036741,9.75055656 L8.77011965,9.75055656 L9.17879909,6.58128114 L6.04036741,6.58128114 L6.04036741,4.5577355 C6.04036741,3.6401345 6.29523835,3.01481066 7.61110083,3.01481066 L9.28937715,3.01407238 L9.28937715,0.179484266 C8.9989867,0.14084757 8.00279981,0.054550748 6.84386302,0.054550748 C4.42410668,0.054550748 2.76756863,1.5315225 2.76756863,4.24396616 L2.76756863,6.58128114 L0.0308437312,6.58128114 L0.0308437312,9.75055656 L2.76756863,9.75055656 L2.76756863,17.8828836 L6.04036741,17.8828836" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(20.000000, 88.000000)" fill="#55ACEE">
|
||||
<path d="M1.0664056,0 L19.9335816,0 C20.5225656,0 20.9999872,0.477446193 20.9999872,1.0664056 L20.9999872,19.9335816 C20.9999872,20.5225656 20.5225656,20.9999872 19.9335816,20.9999872 L1.0664056,20.9999872 C0.477446193,20.9999872 0,20.5225656 0,19.9335816 L0,1.0664056 C0,0.477446193 0.477446193,0 1.0664056,0" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(22.953123, 92.347654)" fill="#FFFFFF">
|
||||
<path d="M15.1674868,1.45563544 C14.6094286,1.70252474 14.0096985,1.86939261 13.3801911,1.94444296 C14.0226594,1.56023342 14.5160771,0.951881842 14.7483894,0.227029549 C14.1471828,0.582725035 13.4812535,0.8410823 12.7724219,0.980330262 C12.204766,0.377007192 11.3960204,0 10.50106,0 C8.78250637,0 7.3891244,1.38987923 7.3891244,3.10420006 C7.3891244,3.3474964 7.41676892,3.58442711 7.46976107,3.81162072 C4.88355523,3.68216729 2.59066014,2.44630984 1.055881,0.568230122 C0.78802452,1.0267107 0.634568754,1.55995452 0.634568754,2.12884909 C0.634568754,3.20581211 1.18398092,4.15594669 2.01894369,4.71261041 C1.50881627,4.69653229 1.02902398,4.55691519 0.609442597,4.32435674 C0.609221113,4.33731767 0.609221113,4.35036063 0.609221113,4.36340359 C0.609221113,5.86744564 1.68185288,7.12211285 3.10538951,7.4072533 C2.84425139,7.47821029 2.56935664,7.5161087 2.28552048,7.5161087 C2.08501982,7.5161087 1.89006447,7.49674934 1.70010482,7.46049155 C2.09606122,8.69374861 3.24525271,9.59125197 4.60693782,9.61635351 C3.54195956,10.4488882 2.20019163,10.9451769 0.742308532,10.9451769 C0.491153607,10.9451769 0.243443992,10.9304933 0,10.9017824 C1.37710697,11.7825514 3.0128009,12.2964769 4.77009787,12.2964769 C10.4937592,12.2964769 13.6236597,7.56631179 13.6236597,3.46416117 C13.6236597,3.32956438 13.6207065,3.19568126 13.6147183,3.06257743 C14.2226515,2.62490817 14.7502762,2.07815381 15.1674868,1.45563544" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(20.000000, 129.000000)" fill="#D53F71">
|
||||
<path d="M20.1250023,0 C20.6082716,0 21.0000024,0.391751006 21.0000024,0.8750001 L21.0000024,20.1250023 C21.0000024,20.6082716 20.6082716,21.0000024 20.1250023,21.0000024 L0.8750001,21.0000024 C0.391751006,21.0000024 0,20.6082716 0,20.1250023 L0,0.8750001 C0,0.391751006 0.391751006,0 0.8750001,0 L20.1250023,0 Z" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(22.692000, 131.692000)" fill="#FFFFFF">
|
||||
<path d="M0,4.51006686 C0,2.01922427 2.01943965,0 4.51006686,0 L11.1052993,0 C13.5961554,0 15.6153864,2.01943965 15.6153864,4.51006686 L15.6153864,11.1052993 C15.6153864,13.5961554 13.5959535,15.6153864 11.1052993,15.6153864 L4.51006686,15.6153864 C2.01922427,15.6153864 0,13.5959535 0,11.1052993 L0,4.51006686 M1.4134617,4.64466207 C1.4134617,2.86011956 2.86024744,1.4134617 4.64466207,1.4134617 L10.9707513,1.4134617 C12.7552803,1.4134617 14.2019247,2.86024744 14.2019247,4.64466207 L14.2019247,10.9707513 C14.2019247,12.7552803 12.7551457,14.2019247 10.9707513,14.2019247 L4.64466207,14.2019247 C2.86011956,14.2019247 1.4134617,12.7551457 1.4134617,10.9707513 L1.4134617,4.64466207" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(26.528539, 135.528539)" fill="#FFFFFF">
|
||||
<path d="M3.9711543,7.9423086 C6.1643757,7.9423086 7.9423086,6.1643757 7.9423086,3.9711543 C7.9423086,1.77794636 6.1643757,0 3.9711543,0 C1.77794636,0 0,1.77794636 0,3.9711543 C0,6.1643757 1.77794636,7.9423086 3.9711543,7.9423086 M3.9711543,6.5288469 C5.383741,6.5288469 6.5288469,5.383741 6.5288469,3.9711543 C6.5288469,2.55858106 5.383741,1.4134617 3.9711543,1.4134617 C2.55858106,1.4134617 1.4134617,2.55858106 1.4134617,3.9711543 C1.4134617,5.383741 2.55858106,6.5288469 3.9711543,6.5288469" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(33.663155, 134.384308)" fill="#FFFFFF">
|
||||
<path d="M1.8846156,0.9423078 C1.8846156,1.46273094 1.46273094,1.8846156 0.9423078,1.8846156 C0.421884664,1.8846156 0,1.46273094 0,0.9423078 C0,0.421884664 0.421884664,0 0.9423078,0 C1.46273094,0 1.8846156,0.421884664 1.8846156,0.9423078" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(20.000000, 170.000000)" fill="#231F20">
|
||||
<path d="M1.0664056,0 L19.9335816,0 C20.5225656,0 20.9999872,0.477446193 20.9999872,1.0664056 L20.9999872,19.9335816 C20.9999872,20.5225656 20.5225656,20.9999872 19.9335816,20.9999872 L1.0664056,20.9999872 C0.477446193,20.9999872 0,20.5225656 0,19.9335816 L0,1.0664056 C0,0.477446193 0.477446193,0 1.0664056,0" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(32.714401, 174.758000)" fill="#7F7F7F">
|
||||
<path d="M0.445921603,0.0612928103 C0.240105322,-0.041772748 0.0716952688,0.062456669 0.0716952688,0.292898962 L0.0716952688,9.46008408 L3.82823204,11.3409775 C4.24166929,11.5478601 4.57676674,11.402993 4.57676674,11.0174464 L4.57676674,2.18971624 C4.57676674,2.15286783 4.55584879,2.11898074 4.52295427,2.10242684 L0.445921603,0.0612928103" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(28.202685, 174.758000)" fill="#ABACAD">
|
||||
<path d="M0.0784382334,7.20449678 L4.36414187,0.230697984 C4.48472774,0.0343948618 4.75182132,-0.041772748 4.9576376,0.0612928103 L9.03926402,2.10475653 C9.0657601,2.1180784 9.07519369,2.15118619 9.05968979,2.17640258 L4.58341127,9.46008408 L0.0784382334,7.20449678" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(24.019094, 174.840031)" fill="#D8D8D8">
|
||||
<path d="M0.380692034,0.104182085 C0.0375637271,-0.0676804158 -0.0786629989,0.0594381669 0.122409418,0.386609764 L4.26202943,7.12246558 L8.76700247,9.37805288 L4.27171732,2.06319952 C4.26538451,2.05285539 4.25621342,2.04457844 4.2453689,2.03914798 L0.380692034,0.104182085" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(23.773000, 174.758000)" fill="#FFFFFF">
|
||||
<path d="M4.50812303,11.0174464 C4.50812303,11.4015165 4.22737125,11.5751765 3.88436599,11.4034032 L0.377285278,9.64744334 C0.171433724,9.54433012 0.00303408799,9.27149435 0.00303408799,9.04106871 L0.00303408799,0.432562002 C0.00303408799,0.125304299 0.22761033,-0.0137110229 0.502035046,0.123753089 L4.4838418,2.1172991 C4.49869765,2.12480495 4.50812303,2.13993151 4.50812303,2.1564854 L4.50812303,11.0174464" id="Shape"></path>
|
||||
</g>
|
||||
<g id="social-networks" transform="translate(18.000000, 18.000000)">
|
||||
<rect id="Rectangle" fill-opacity="0.699999988" fill="#B3B3B3" x="62" y="36" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.699999988" fill="#B3B3B3" x="62" y="77" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.699999988" fill="#B3B3B3" x="62" y="118" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.699999988" fill="#B3B3B3" x="62" y="159" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.699999988" fill="#B3B3B3" x="305" y="77" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.699999988" fill="#B3B3B3" x="305" y="36" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#52CA6F" x="305" y="118" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.699999988" fill="#B3B3B3" x="305" y="159" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.799999952" fill="#C9C9C9" x="33" y="77" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.799999952" fill="#C9C9C9" x="33" y="36" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.799999952" fill="#C9C9C9" x="33" y="118" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.799999952" fill="#C9C9C9" x="33" y="159" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#B3B3B3" x="0" y="0" width="138" height="9" rx="4.5"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Group-2">
|
||||
<g id="Group" stroke="#F5F5F5" fill="#FFFFFF">
|
||||
<polygon id="Shape" points="435 0 435 73 0 73 0 0"></polygon>
|
||||
</g>
|
||||
<g id="Group" transform="translate(29.000000, 32.000000)" fill="#CCCCCC">
|
||||
<path d="M12.5,11 L11.71,11 L11.43,10.73 C12.41,9.59 13,8.11 13,6.5 C13,2.91 10.09,0 6.5,0 C2.91,0 0,2.91 0,6.5 C0,10.09 2.91,13 6.5,13 C8.11,13 9.59,12.41 10.73,11.43 L11,11.71 L11,12.5 L16,17.49 L17.49,16 L12.5,11 M6.5,11 C4.01,11 2,8.99 2,6.5 C2,4.01 4.01,2 6.5,2 C8.99,2 11,4.01 11,6.5 C11,8.99 8.99,11 6.5,11" id="Shape"></path>
|
||||
</g>
|
||||
</g>
|
||||
<g id="topone" transform="translate(24.000000, 96.000000)">
|
||||
<g id="top-one" fill="#F5F5F5">
|
||||
<path d="M381,0 C382.657,0 384,1.343 384,3 L384,253 C384,254.657 382.657,256 381,256 L3,256 C1.343,256 0,254.657 0,253 L0,3 C0,1.343 1.343,0 3,0 L381,0 Z" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(19.000000, 88.000000)" fill-opacity="0.0392157" fill="#000000">
|
||||
<polyline id="Shape" points="0 0 346 0 346 23 0 23 0 0"></polyline>
|
||||
</g>
|
||||
<g id="Group" transform="translate(12.000000, 80.000000)" fill="#FFFFFF">
|
||||
<path d="M3,0 L357,0 C358.657,0 360,1.343 360,3 L360,34 C360,35.657 358.657,37 357,37 L3,37 C1.343,37 0,35.657 0,34 L0,3 C0,1.343 1.343,0 3,0" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(19.000000, 129.000000)" fill-opacity="0.0392157" fill="#000000">
|
||||
<polyline id="Shape" points="0 0 346 0 346 23 0 23 0 0"></polyline>
|
||||
</g>
|
||||
<g id="Group" transform="translate(12.000000, 121.000000)" fill="#FFFFFF">
|
||||
<path d="M3,0 L357,0 C358.657,0 360,1.343 360,3 L360,34 C360,35.657 358.657,37 357,37 L3,37 C1.343,37 0,35.657 0,34 L0,3 C0,1.343 1.343,0 3,0" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(19.000000, 170.000000)" fill-opacity="0.0392157" fill="#000000">
|
||||
<polyline id="Shape" points="0 0 346 0 346 23 0 23 0 0"></polyline>
|
||||
</g>
|
||||
<g id="Group" transform="translate(12.000000, 162.000000)" fill="#FFFFFF">
|
||||
<path d="M3,0 L357,0 C358.657,0 360,1.343 360,3 L360,34 C360,35.657 358.657,37 357,37 L3,37 C1.343,37 0,35.657 0,34 L0,3 C0,1.343 1.343,0 3,0" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(19.000000, 211.000000)" fill-opacity="0.0392157" fill="#000000">
|
||||
<polyline id="Shape" points="0 0 346 0 346 23 0 23 0 0"></polyline>
|
||||
</g>
|
||||
<g id="Group" transform="translate(12.000000, 203.000000)" fill="#FFFFFF">
|
||||
<path d="M3,0 L357,0 C358.657,0 360,1.343 360,3 L360,34 C360,35.657 358.657,37 357,37 L3,37 C1.343,37 0,35.657 0,34 L0,3 C0,1.343 1.343,0 3,0" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(136.000000, 88.000000)"></g>
|
||||
<rect id="Rectangle" fill-opacity="0.900000036" fill="#3FC55F" x="323" y="95" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.900000036" fill="#3FC55F" x="323" y="218" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#C6C6C6" x="323" y="54" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.900000036" fill="#3FC55F" x="323" y="136" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.900000036" fill="#3FC55F" x="323" y="177" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.5" fill="#B3B3B3" x="53" y="56" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.5" fill="#B3B3B3" x="53" y="97" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.5" fill="#B3B3B3" x="53" y="138" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.5" fill="#B3B3B3" x="53" y="179" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.5" fill="#B3B3B3" x="53" y="219" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.300000012" fill="#B3B3B3" x="138" y="97" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.300000012" fill="#B3B3B3" x="138" y="56" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.300000012" fill="#B3B3B3" x="138" y="138" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.300000012" fill="#B3B3B3" x="138" y="179" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.300000012" fill="#B3B3B3" x="138" y="219" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#C9C9C9" x="25" y="97" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#C9C9C9" x="25" y="56" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#C9C9C9" x="25" y="138" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#C9C9C9" x="25" y="179" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#C9C9C9" x="25" y="219" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#B3B3B3" x="18" y="15" width="110" height="9" rx="4.5"></rect>
|
||||
</g>
|
||||
<g id="bottom-one" transform="translate(24.000000, 594.000000)">
|
||||
<g id="top-one" fill="#F5F5F5">
|
||||
<path d="M381,0 C382.657,0 384,1.343 384,3 L384,253 C384,254.657 382.657,256 381,256 L3,256 C1.343,256 0,254.657 0,253 L0,3 C0,1.343 1.343,0 3,0 L381,0 Z" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(19.000000, 88.000000)" fill-opacity="0.0392157" fill="#000000">
|
||||
<polyline id="Shape" points="0 0 346 0 346 23 0 23 0 0"></polyline>
|
||||
</g>
|
||||
<g id="Group" transform="translate(12.000000, 80.000000)" fill="#FFFFFF">
|
||||
<path d="M3,0 L357,0 C358.657,0 360,1.343 360,3 L360,34 C360,35.657 358.657,37 357,37 L3,37 C1.343,37 0,35.657 0,34 L0,3 C0,1.343 1.343,0 3,0" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(12.000000, 40.000000)" fill="#FFFFFF">
|
||||
<path d="M3,0 L357,0 C358.657,0 360,1.343 360,3 L360,34 C360,35.657 358.657,37 357,37 L3,37 C1.343,37 0,35.657 0,34 L0,3 C0,1.343 1.343,0 3,0" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(19.000000, 129.000000)" fill-opacity="0.0392157" fill="#000000">
|
||||
<polyline id="Shape" points="0 0 346 0 346 23 0 23 0 0"></polyline>
|
||||
</g>
|
||||
<g id="Group" transform="translate(12.000000, 121.000000)" fill="#FFFFFF">
|
||||
<path d="M3,0 L357,0 C358.657,0 360,1.343 360,3 L360,34 C360,35.657 358.657,37 357,37 L3,37 C1.343,37 0,35.657 0,34 L0,3 C0,1.343 1.343,0 3,0" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(19.000000, 170.000000)" fill-opacity="0.0392157" fill="#000000">
|
||||
<polyline id="Shape" points="0 0 346 0 346 23 0 23 0 0"></polyline>
|
||||
</g>
|
||||
<g id="Group" transform="translate(12.000000, 162.000000)" fill="#FFFFFF">
|
||||
<path d="M3,0 L357,0 C358.657,0 360,1.343 360,3 L360,34 C360,35.657 358.657,37 357,37 L3,37 C1.343,37 0,35.657 0,34 L0,3 C0,1.343 1.343,0 3,0" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(19.000000, 211.000000)" fill-opacity="0.0392157" fill="#000000">
|
||||
<polyline id="Shape" points="0 0 346 0 346 23 0 23 0 0"></polyline>
|
||||
</g>
|
||||
<g id="Group" transform="translate(12.000000, 203.000000)" fill="#FFFFFF">
|
||||
<path d="M3,0 L357,0 C358.657,0 360,1.343 360,3 L360,34 C360,35.657 358.657,37 357,37 L3,37 C1.343,37 0,35.657 0,34 L0,3 C0,1.343 1.343,0 3,0" id="Shape"></path>
|
||||
</g>
|
||||
<g id="Group" transform="translate(136.000000, 88.000000)"></g>
|
||||
<rect id="Rectangle" fill-opacity="0.900000036" fill="#3FC55F" x="323" y="95" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.900000036" fill="#3FC55F" x="323" y="218" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#52CA6F" x="323" y="54" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.900000036" fill="#3FC55F" x="323" y="136" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.900000036" fill="#3FC55F" x="323" y="177" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.5" fill="#B3B3B3" x="53" y="56" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.5" fill="#B3B3B3" x="53" y="97" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.5" fill="#B3B3B3" x="53" y="138" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.5" fill="#B3B3B3" x="53" y="179" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.5" fill="#B3B3B3" x="53" y="219" width="76" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.300000012" fill="#B3B3B3" x="138" y="97" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.300000012" fill="#B3B3B3" x="138" y="56" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.300000012" fill="#B3B3B3" x="138" y="138" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.300000012" fill="#B3B3B3" x="138" y="179" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill-opacity="0.300000012" fill="#B3B3B3" x="138" y="219" width="41" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#C9C9C9" x="25" y="97" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#C9C9C9" x="25" y="56" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#C9C9C9" x="25" y="138" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#C9C9C9" x="25" y="179" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#C9C9C9" x="25" y="219" width="20.0703125" height="9" rx="4.5"></rect>
|
||||
<rect id="Rectangle" fill="#B3B3B3" x="18" y="15" width="110" height="9" rx="4.5"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="default-tlds" transform="translate(59.000000, 29.000000)" fill="#B3B3B3" fill-opacity="0.699999988">
|
||||
<rect id="Rectangle" x="0" y="0" width="123" height="13" rx="6.5"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 268 B |
|
Before Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 461 KiB |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 321 KiB |
|
Before Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 388 KiB |
|
Before Width: | Height: | Size: 388 KiB |
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 441 KiB |
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 321 KiB |