diff --git a/.env.example b/.env.example index fcaa2c6..af607d6 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,16 @@ WINLOOP_ONLYOFFICE_JWT_SECRET= WINLOOP_PUBLIC_BASE_URL= # 项目资源临时访问地址有效期(秒) WINLOOP_PROJECT_RESOURCE_ACCESS_URL_TTL_SECONDS=600 + +# ===== Sentry(可选;未配置时应用仍可运行,只是不启用上报)===== +WINLOOP_SENTRY_DSN= +# 仅支持 staging / production;本地开发可留空 +WINLOOP_SENTRY_ENVIRONMENT= +WINLOOP_SENTRY_TRACES_SAMPLE_RATE=0.1 +# 可选;默认复用 WINLOOP_BUILD_VERSION +WINLOOP_SENTRY_RELEASE= + +# ===== Sentry 构建期参数(仅 CI/CD 构建并上传 source map 时需要)===== +SENTRY_AUTH_TOKEN= +WINLOOP_SENTRY_ORG= +WINLOOP_SENTRY_PROJECT= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7503496..4e4eaf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,9 +60,8 @@ jobs: - name: Project Visibility Guard run: pnpm run test:project-visibility - build_and_smoke: + test_unit: runs-on: ubuntu-latest - timeout-minutes: 30 steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 @@ -79,8 +78,56 @@ jobs: - name: Install run: pnpm install --frozen-lockfile - - name: Build - run: pnpm run build + - name: Unit Tests + run: pnpm run test:unit + + smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: lts/* + package-manager-cache: false + + - name: Enable Corepack (pnpm) + run: | + corepack enable + corepack prepare pnpm@10.29.2 --activate + pnpm --version + + - name: Install + run: pnpm install --frozen-lockfile - name: Smoke - run: pnpm run ci:smoke + run: pnpm run test:smoke + + e2e_smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: lts/* + package-manager-cache: false + + - name: Enable Corepack (pnpm) + run: | + corepack enable + corepack prepare pnpm@10.29.2 --activate + pnpm --version + + - name: Install + run: pnpm install --frozen-lockfile + + - name: E2E Smoke + env: + WINLOOP_PG_URL: ${{ secrets.WINLOOP_PG_URL }} + run: | + if [ -z "$WINLOOP_PG_URL" ]; then + echo "WINLOOP_PG_URL 未配置,跳过 E2E smoke。" + exit 0 + fi + pnpm run test:e2e diff --git a/.github/workflows/github-feishu-notify.yml b/.github/workflows/github-feishu-notify.yml new file mode 100644 index 0000000..b92f598 --- /dev/null +++ b/.github/workflows/github-feishu-notify.yml @@ -0,0 +1,189 @@ +name: GitHub Feishu Notify + +on: + issues: + types: + - opened + - reopened + pull_request_target: + types: + - opened + - reopened + - ready_for_review + +permissions: + contents: read + +jobs: + notify-feishu: + name: Notify Feishu + runs-on: ubuntu-latest + steps: + - name: Check webhook configuration + id: config + env: + FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_GITHUB_WEBHOOK_URL }} + run: | + set -euo pipefail + + if [ -n "${FEISHU_WEBHOOK_URL}" ]; then + echo "enabled=true" >> "${GITHUB_OUTPUT}" + else + echo "enabled=false" >> "${GITHUB_OUTPUT}" + echo "Skip Feishu notification because FEISHU_GITHUB_WEBHOOK_URL is not configured." + fi + + - name: Send Feishu notification + if: steps.config.outputs.enabled == 'true' + env: + FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_GITHUB_WEBHOOK_URL }} + FEISHU_WEBHOOK_SECRET: ${{ secrets.FEISHU_GITHUB_WEBHOOK_SECRET }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_EVENT_ACTION: ${{ github.event.action }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_RUN_ID: ${{ github.run_id }} + GITHUB_EVENT_PATH: ${{ github.event_path }} + run: | + set -euo pipefail + + python3 <<'PY' + import base64 + import hashlib + import hmac + import json + import os + import time + import urllib.request + + def to_text(value) -> str: + return str(value or "").strip() + + def shorten(value: str, limit: int = 180) -> str: + text = to_text(value) + if len(text) <= limit: + return text + return f"{text[: max(0, limit - 3)]}..." + + def read_event_payload() -> dict: + path = to_text(os.environ.get("GITHUB_EVENT_PATH")) + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + def build_issue_message(payload: dict) -> str: + issue = payload.get("issue") or {} + action = to_text(os.environ.get("GITHUB_EVENT_ACTION")) + number = issue.get("number") or payload.get("number") or "-" + title = to_text(issue.get("title")) or "-" + url = to_text(issue.get("html_url")) + labels = ", ".join( + to_text(item.get("name")) + for item in (issue.get("labels") or []) + if to_text(item.get("name")) + ) or "-" + body_preview = shorten(issue.get("body") or "", 220) or "-" + actor = to_text(os.environ.get("GITHUB_ACTOR")) or "-" + repository = to_text(os.environ.get("GITHUB_REPOSITORY")) or "-" + action_label = "新建" if action == "opened" else "重新打开" + run_url = f"{to_text(os.environ.get('GITHUB_SERVER_URL'))}/{repository}/actions/runs/{to_text(os.environ.get('GITHUB_RUN_ID'))}" + return "\n".join([ + "GitHub Issue 通知", + f"动作:{action_label}", + f"仓库:{repository}", + f"Issue:#{number} {title}", + f"发起人:{actor}", + f"标签:{labels}", + f"摘要:{body_preview}", + f"链接:{url}", + f"Workflow:{run_url}", + ]) + + def build_pr_message(payload: dict) -> str: + pr = payload.get("pull_request") or {} + action = to_text(os.environ.get("GITHUB_EVENT_ACTION")) + number = pr.get("number") or payload.get("number") or "-" + title = to_text(pr.get("title")) or "-" + url = to_text(pr.get("html_url")) + actor = to_text(os.environ.get("GITHUB_ACTOR")) or "-" + repository = to_text(os.environ.get("GITHUB_REPOSITORY")) or "-" + base_ref = to_text(((pr.get("base") or {}).get("ref"))) or "-" + head_ref = to_text(((pr.get("head") or {}).get("ref"))) or "-" + is_draft = bool(pr.get("draft")) + labels = ", ".join( + to_text(item.get("name")) + for item in (pr.get("labels") or []) + if to_text(item.get("name")) + ) or "-" + body_preview = shorten(pr.get("body") or "", 220) or "-" + action_label_map = { + "opened": "新建", + "reopened": "重新打开", + "ready_for_review": "转为可评审", + } + action_label = action_label_map.get(action, action or "触发") + draft_label = "是" if is_draft else "否" + run_url = f"{to_text(os.environ.get('GITHUB_SERVER_URL'))}/{repository}/actions/runs/{to_text(os.environ.get('GITHUB_RUN_ID'))}" + return "\n".join([ + "GitHub PR 通知", + f"动作:{action_label}", + f"仓库:{repository}", + f"PR:#{number} {title}", + f"发起人:{actor}", + f"分支:{head_ref} -> {base_ref}", + f"Draft:{draft_label}", + f"标签:{labels}", + f"摘要:{body_preview}", + f"链接:{url}", + f"Workflow:{run_url}", + ]) + + def build_message() -> str: + payload = read_event_payload() + event_name = to_text(os.environ.get("GITHUB_EVENT_NAME")) + if event_name == "issues": + return build_issue_message(payload) + if event_name == "pull_request_target": + return build_pr_message(payload) + raise SystemExit(f"Unsupported event: {event_name}") + + def build_signature(secret: str) -> tuple[str, str]: + timestamp = str(int(time.time())) + string_to_sign = f"{timestamp}\n{secret}" + signature = base64.b64encode( + hmac.new(string_to_sign.encode("utf-8"), digestmod=hashlib.sha256).digest() + ).decode("utf-8") + return timestamp, signature + + webhook_url = to_text(os.environ.get("FEISHU_WEBHOOK_URL")) + webhook_secret = to_text(os.environ.get("FEISHU_WEBHOOK_SECRET")) + if not webhook_url: + raise SystemExit("Missing FEISHU_GITHUB_WEBHOOK_URL") + + payload = { + "msg_type": "text", + "content": { + "text": build_message(), + }, + } + if webhook_secret: + timestamp, sign = build_signature(webhook_secret) + payload["timestamp"] = timestamp + payload["sign"] = sign + + request = urllib.request.Request( + webhook_url, + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json; charset=utf-8"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=15) as response: + body = response.read().decode("utf-8", errors="replace") + + result = json.loads(body) if body else {} + code = result.get("code", 0) + if code not in (0, "0", None): + raise SystemExit(f"Feishu webhook returned code={code}, msg={result.get('msg', '')}") + + print("Feishu notification sent successfully.") + PY diff --git a/.github/workflows/winloop-image.yml b/.github/workflows/winloop-image.yml index 4ca6545..58349ea 100644 --- a/.github/workflows/winloop-image.yml +++ b/.github/workflows/winloop-image.yml @@ -18,15 +18,109 @@ jobs: publish-ghcr: name: Build and Push WinLoop Image runs-on: ubuntu-latest - timeout-minutes: 90 + timeout-minutes: 180 permissions: + actions: read contents: read packages: write steps: + - name: Wait for matching CI run + id: ci_gate + if: github.event_name == 'push' + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + const timeoutMs = 30 * 60 * 1000; + const pollMs = 10 * 1000; + const deadline = Date.now() + timeoutMs; + const sha = context.sha; + const branch = context.ref.replace('refs/heads/', ''); + const allowSmokeOnlyGate = branch === 'dev'; + + async function resolveRunJobs(runId) { + const { data } = await github.request('GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs', { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + per_page: 100, + }); + return data.jobs || []; + } + + while (Date.now() < deadline) { + const { data } = await github.request('GET /repos/{owner}/{repo}/actions/runs', { + owner: context.repo.owner, + repo: context.repo.repo, + event: 'push', + head_sha: sha, + per_page: 100, + }); + + const runs = [...(data.workflow_runs || [])] + .filter(run => run.name === 'CI' && run.head_branch === branch) + .sort((left, right) => new Date(right.created_at).getTime() - new Date(left.created_at).getTime()); + const run = runs[0]; + + if (!run) { + core.info(`Waiting for CI run on ${branch}@${sha} to appear...`); + await sleep(pollMs); + continue; + } + + core.setOutput('ci_run_url', run.html_url || ''); + core.setOutput('ci_conclusion', run.conclusion || ''); + + if (run.status !== 'completed') { + core.info(`Waiting for CI run ${run.html_url || run.id} to complete. Current status: ${run.status}.`); + await sleep(pollMs); + continue; + } + + if (run.conclusion === 'success') { + core.setOutput('should_publish', 'true'); + return; + } + + if (allowSmokeOnlyGate) { + const jobs = await resolveRunJobs(run.id); + const smokeJob = jobs.find(job => job.name === 'smoke' || job.name === 'build_and_smoke'); + + if (smokeJob?.conclusion === 'success') { + core.warning(`CI concluded as ${run.conclusion || 'unknown'}, but smoke gate passed on ${branch}; continue image publish.`); + core.setOutput('should_publish', 'true'); + return; + } + } + + core.warning(`Skip image publish because CI concluded as ${run.conclusion || 'unknown'}.`); + core.setOutput('should_publish', 'false'); + return; + } + + core.setFailed(`Timed out waiting for CI run on ${branch}@${sha}.`); + + - name: Skip publish because CI did not pass + if: github.event_name == 'push' && steps.ci_gate.outputs.should_publish != 'true' + env: + CI_CONCLUSION: ${{ steps.ci_gate.outputs.ci_conclusion }} + CI_RUN_URL: ${{ steps.ci_gate.outputs.ci_run_url }} + run: | + set -euo pipefail + + echo "Skip image publish because CI did not pass." + echo "Conclusion: ${CI_CONCLUSION:-unknown}" + if [ -n "${CI_RUN_URL:-}" ]; then + echo "CI Run: ${CI_RUN_URL}" + fi + - name: Checkout code + if: github.event_name == 'workflow_dispatch' || steps.ci_gate.outputs.should_publish == 'true' uses: actions/checkout@v5 - name: Resolve image name + if: github.event_name == 'workflow_dispatch' || steps.ci_gate.outputs.should_publish == 'true' id: image env: GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} @@ -34,10 +128,25 @@ jobs: owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" echo "name=ghcr.io/${owner}/touch-win-loop" >> "${GITHUB_OUTPUT}" + - name: Resolve build metadata + if: github.event_name == 'workflow_dispatch' || steps.ci_gate.outputs.should_publish == 'true' + id: build_meta + env: + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + short_sha="${GITHUB_SHA:0:7}" + build_version="${GITHUB_REF_NAME}-${GITHUB_RUN_NUMBER}-${short_sha}" + echo "build_version=${build_version}" >> "${GITHUB_OUTPUT}" + - name: Setup Docker Buildx + if: github.event_name == 'workflow_dispatch' || steps.ci_gate.outputs.should_publish == 'true' uses: docker/setup-buildx-action@v4 - name: Login to GHCR + if: github.event_name == 'workflow_dispatch' || steps.ci_gate.outputs.should_publish == 'true' uses: docker/login-action@v4 with: registry: ghcr.io @@ -45,6 +154,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Extract image metadata + if: github.event_name == 'workflow_dispatch' || steps.ci_gate.outputs.should_publish == 'true' id: meta uses: docker/metadata-action@v6 with: @@ -55,52 +165,56 @@ jobs: type=raw,value=dev-latest,enable=${{ github.ref_name == 'dev' }} - name: Build and push image + if: github.event_name == 'workflow_dispatch' || steps.ci_gate.outputs.should_publish == 'true' id: build uses: docker/build-push-action@v7 with: context: . file: Dockerfile + build-args: | + WINLOOP_BUILD_VERSION=${{ steps.build_meta.outputs.build_version }} + WINLOOP_BUILD_COMMIT_SHA=${{ github.sha }} + WINLOOP_SENTRY_ORG=${{ secrets.WINLOOP_SENTRY_ORG }} + WINLOOP_SENTRY_PROJECT=${{ secrets.WINLOOP_SENTRY_PROJECT }} + WINLOOP_SENTRY_RELEASE=${{ steps.build_meta.outputs.build_version }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + secrets: | + sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }} - name: Output image digest + if: github.event_name == 'workflow_dispatch' || steps.ci_gate.outputs.should_publish == 'true' env: + BUILD_VERSION: ${{ steps.build_meta.outputs.build_version }} IMAGE_REPO: ${{ steps.image.outputs.name }} IMAGE_DIGEST: ${{ steps.build.outputs.digest }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SHA: ${{ github.sha }} run: | - short_sha="${GITHUB_SHA:0:7}" - build_version="${GITHUB_REF_NAME}-${GITHUB_RUN_NUMBER}-${short_sha}" image_ref="${IMAGE_REPO}@${IMAGE_DIGEST}" echo "Image: ${{ steps.image.outputs.name }}" echo "Tags:" echo "${{ steps.meta.outputs.tags }}" echo "Digest: ${{ steps.build.outputs.digest }}" - echo "Build Version: ${build_version}" + echo "Build Version: ${BUILD_VERSION}" echo "Image Ref: ${image_ref}" - - name: Resolve build metadata - id: build_meta + - name: Resolve image ref + if: github.event_name == 'workflow_dispatch' || steps.ci_gate.outputs.should_publish == 'true' + id: image_ref_meta env: IMAGE_REPO: ${{ steps.image.outputs.name }} IMAGE_DIGEST: ${{ steps.build.outputs.digest }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SHA: ${{ github.sha }} run: | set -euo pipefail - short_sha="${GITHUB_SHA:0:7}" - build_version="${GITHUB_REF_NAME}-${GITHUB_RUN_NUMBER}-${short_sha}" image_ref="${IMAGE_REPO}@${IMAGE_DIGEST}" - echo "build_version=${build_version}" >> "${GITHUB_OUTPUT}" echo "image_ref=${image_ref}" >> "${GITHUB_OUTPUT}" - name: Trigger Jenkins deployment + if: github.event_name == 'workflow_dispatch' || steps.ci_gate.outputs.should_publish == 'true' env: JENKINS_BASE_URL: ${{ secrets.JENKINS_BASE_URL }} JENKINS_USER: ${{ secrets.JENKINS_USER }} @@ -110,152 +224,12 @@ jobs: GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_SHA: ${{ github.sha }} + GITHUB_ACTOR: ${{ github.actor }} BUILD_VERSION: ${{ steps.build_meta.outputs.build_version }} - IMAGE_REF: ${{ steps.build_meta.outputs.image_ref }} - TRIGGERED_BY: github-actions + IMAGE_REF: ${{ steps.image_ref_meta.outputs.image_ref }} + TRIGGERED_BY: ${{ github.event_name }} WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail - python3 <<'PY' - import base64 - import json - import os - import sys - import time - import urllib.error - import urllib.parse - import urllib.request - - def require(name: str) -> str: - value = os.environ.get(name, '').strip() - if not value: - raise SystemExit(f'Missing required environment variable: {name}') - return value - - base_url = require('JENKINS_BASE_URL').rstrip('/') - username = require('JENKINS_USER') - token = require('JENKINS_API_TOKEN') - repository = require('GITHUB_REPOSITORY') - branch = require('GITHUB_REF_NAME') - commit_sha = require('GITHUB_SHA') - build_version = require('BUILD_VERSION') - image_ref = require('IMAGE_REF') - triggered_by = require('TRIGGERED_BY') - workflow_run_url = require('WORKFLOW_RUN_URL') - - if branch == 'dev': - job_name = require('JENKINS_JOB_STAGING') - elif branch == 'main': - job_name = require('JENKINS_JOB_PRODUCTION') - else: - print(f'Skip Jenkins deployment for unsupported branch: {branch}') - raise SystemExit(0) - - auth_token = base64.b64encode(f'{username}:{token}'.encode('utf-8')).decode('ascii') - default_headers = { - 'Authorization': f'Basic {auth_token}', - 'Accept': 'application/json', - } - - def job_path(name: str) -> str: - parts = [part for part in name.strip('/').split('/') if part] - return ''.join(f"/job/{urllib.parse.quote(part, safe='')}" for part in parts) - - def api_url(url: str, suffix: str = 'api/json') -> str: - if not url.endswith('/'): - url = f'{url}/' - return urllib.parse.urljoin(url, suffix) - - def request_json(url: str, headers=None): - request_headers = dict(default_headers) - if headers: - request_headers.update(headers) - request = urllib.request.Request(url, headers=request_headers) - with urllib.request.urlopen(request, timeout=30) as response: - return json.load(response) - - crumb_headers = {} - crumb_url = f'{base_url}/crumbIssuer/api/json' - try: - crumb_payload = request_json(crumb_url) - except urllib.error.HTTPError as exc: - if exc.code not in (401, 403, 404): - raise - crumb_payload = {} - except urllib.error.URLError: - crumb_payload = {} - - crumb_field = str(crumb_payload.get('crumbRequestField') or '').strip() - crumb_value = str(crumb_payload.get('crumb') or '').strip() - if crumb_field and crumb_value: - crumb_headers[crumb_field] = crumb_value - print(f'Fetched Jenkins crumb via {crumb_field}.') - else: - print('Jenkins crumb issuer is unavailable or not required, continue without crumb header.') - - params = urllib.parse.urlencode({ - 'GITHUB_REPOSITORY': repository, - 'GITHUB_BRANCH': branch, - 'BUILD_COMMIT_SHA': commit_sha, - 'BUILD_VERSION': build_version, - 'IMAGE_REF': image_ref, - 'TRIGGERED_BY': triggered_by, - 'WORKFLOW_RUN_URL': workflow_run_url, - }).encode('utf-8') - trigger_headers = dict(default_headers) - trigger_headers.update(crumb_headers) - trigger_headers['Content-Type'] = 'application/x-www-form-urlencoded' - trigger_url = f'{base_url}{job_path(job_name)}/buildWithParameters' - trigger_request = urllib.request.Request(trigger_url, data=params, headers=trigger_headers, method='POST') - - with urllib.request.urlopen(trigger_request, timeout=30) as response: - location = str(response.headers.get('Location') or '').strip() - status = response.getcode() - if status not in (200, 201, 202): - raise SystemExit(f'Unexpected Jenkins trigger status: {status}') - if not location: - raise SystemExit('Jenkins trigger did not return a queue location header') - - if location.startswith('/'): - location = f'{base_url}{location}' - print(f'Queued Jenkins build at: {location}') - - queue_deadline = time.time() + 15 * 60 - build_url = '' - while time.time() < queue_deadline: - queue_payload = request_json(api_url(location)) - if queue_payload.get('cancelled'): - why = str(queue_payload.get('why') or 'cancelled') - raise SystemExit(f'Jenkins queue item was cancelled: {why}') - executable = queue_payload.get('executable') or {} - build_url = str(executable.get('url') or '').strip() - if build_url: - break - why = str(queue_payload.get('why') or '').strip() - if why: - print(f'Waiting Jenkins queue: {why}') - time.sleep(5) - - if not build_url: - raise SystemExit('Timed out waiting for Jenkins queue item to start') - print(f'Jenkins build started: {build_url}') - - build_deadline = time.time() + 90 * 60 - result = '' - while time.time() < build_deadline: - build_payload = request_json(api_url(build_url)) - if build_payload.get('building'): - print('Jenkins build is still running...') - time.sleep(10) - continue - result = str(build_payload.get('result') or '').strip() - break - - if not result: - raise SystemExit('Timed out waiting for Jenkins build result') - - print(f'Jenkins build result: {result}') - if result != 'SUCCESS': - raise SystemExit(f'Jenkins deployment failed with result: {result}') - PY + python3 "scripts/ci/trigger_jenkins_deploy.py" diff --git a/.gitignore b/.gitignore index df60b33..e7945dd 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ public/assets/fonts tmp/document-storage/ .golutra/ output/ +__pycache__/ +*.py[cod] diff --git a/Dockerfile b/Dockerfile index 8d32591..1550536 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,12 +3,26 @@ FROM node:20-alpine AS build-stage WORKDIR /app RUN corepack enable +ARG WINLOOP_BUILD_VERSION= +ARG WINLOOP_BUILD_COMMIT_SHA= +ARG WINLOOP_SENTRY_ORG= +ARG WINLOOP_SENTRY_PROJECT= +ARG WINLOOP_SENTRY_RELEASE= + +ENV WINLOOP_BUILD_VERSION=${WINLOOP_BUILD_VERSION} +ENV WINLOOP_BUILD_COMMIT_SHA=${WINLOOP_BUILD_COMMIT_SHA} +ENV WINLOOP_SENTRY_ORG=${WINLOOP_SENTRY_ORG} +ENV WINLOOP_SENTRY_PROJECT=${WINLOOP_SENTRY_PROJECT} +ENV WINLOOP_SENTRY_RELEASE=${WINLOOP_SENTRY_RELEASE} + COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \ pnpm install --frozen-lockfile COPY . . -RUN pnpm build +RUN --mount=type=secret,id=sentry_auth_token,required=false \ + export SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token 2>/dev/null || true)" && \ + pnpm build # SSR FROM node:20-alpine AS production-stage @@ -19,4 +33,4 @@ COPY --from=build-stage /app/.output ./.output EXPOSE 3000 -CMD ["node", ".output/server/index.mjs"] +CMD ["node", "--import", "./.output/server/sentry.server.config.mjs", ".output/server/index.mjs"] diff --git a/README.md b/README.md index 68edfc1..86e11f7 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,12 @@ WinLoop AI(赛帮帮)当前是一套面向竞赛团队的项目工作台, - `从本地设备中上传` - 固定 `flow` tab 的用户可见名称统一为 `流程画布`。 - `流程画布` 与资源列表中的同一条 workflow 资源指向同一个底层对象。 +- `协作文档` 在编辑时支持 AI 上下文补齐:系统会基于当前光标附近内容自动预测后续文本,用户按 `Tab` 接受当前建议。 +- AI 补齐按“接受一次建议记一次消耗”计费,当前暂定每次接受消耗 `0.1 credits`。 ## 当前页面入口 -- `/team/:teamId/project/:projectId`:项目工作区主界面 +- `/team/:teamId/project/:projectId`:研发工作台主界面 - `/workspace/:workspaceId/project/:projectId`:工作区项目页别名入口 - `/admin/integrations/feishu`:飞书集成中心 @@ -82,6 +84,86 @@ WINLOOP_CONTEST_AUTO_SEED=false - Redis 主要用于运行时配置信息与预留能力。 - AI / 飞书等敏感配置支持加密存储。 - 资源回收 worker 参数改为后台 UI 管理,不再从 Env 读取。 +- Sentry 为可选能力;未配置 `WINLOOP_SENTRY_DSN` / `WINLOOP_SENTRY_ENVIRONMENT` 时应用仍可正常运行,只是不启用错误上报。 + +如果要检查当前仓库 / Shell 的 Sentry 就绪状态,可执行: + +```bash +pnpm run sentry:doctor --mode production +``` + +输出会区分: + +- 代码接入是否完整 +- 运行期上报是否具备前置条件 +- source map 上传所需的构建期变量是否齐全 + +如果要做 staging 验收,可在满足以下前提后使用内部 smoke 接口: + +- `WINLOOP_SENTRY_ENVIRONMENT=staging` +- 已配置 `WINLOOP_SENTRY_DSN` +- 当前登录用户具备 `contest.read_internal` + +```bash +curl -X POST "https:///api/admin/sentry/smoke" \ + -H "Content-Type: application/json" \ + -H "Cookie: " \ + --data '{"target":"nitro"}' \ + -i + +curl -X POST "https:///api/admin/sentry/smoke" \ + -H "Content-Type: application/json" \ + -H "Cookie: " \ + --data '{"target":"worker"}' +``` + +预期结果: + +- `target=nitro`:返回 `500`,响应头里带 `x-trace-id` +- `target=worker`:返回 `200`,返回体里带 `traceId` / `release` / `environment` +- 若返回 `404`:说明当前不是 `staging` +- 若返回 `412`:说明 Sentry SDK 还没初始化,通常是 `WINLOOP_SENTRY_DSN` 或 `WINLOOP_SENTRY_ENVIRONMENT` 未生效 + +更完整的部署与验收说明见: + +- [Jenkins 部署说明](./deploy/jenkins/README.zh-CN.md) + +## 数据库迁移 + +当前应用在启动阶段会尝试补齐部分 schema,但它不应该成为唯一的迁移入口。 +对于已经在线上运行的环境,建议显式执行 SQL 迁移,再发布依赖新 schema 的镜像。 + +示例: + +```bash +pnpm db:migrate:project-resource-tree +``` + +通用用法: + +```bash +pnpm db:migrate ./scripts/migrations/.sql +``` + +如需重复执行幂等迁移,可追加 `--force`;如不希望写入 `migrations_meta`,可追加 `--no-mark`。 + +## 会议能力配置 + +会议运行时配置已经改为后台维护,不再从应用环境变量直接读取 `RTC / ASR / worker` 参数,也不再默认回退 `mock`。 + +当前约束: + +- `RTC`、`ASR`、`worker` 统一在后台页面 `/admin/meeting-providers` 配置。 +- 当后台配置缺失时,前端会直接禁用“发起会议 / 启动会议 / 加入会议”,并显示明确问题。 +- 应用环境变量里只保留 `WINLOOP_CONFIG_MASTER_KEY` 作为会议密钥加密根密钥。 +- 本地联调真实链路时,使用 [deploy/meeting/README.zh-CN.md](./deploy/meeting/README.zh-CN.md) 和 [docs/meeting-runtime-setup.md](./docs/meeting-runtime-setup.md) 提供的 LiveKit / ASR bring-up 方案。 + +当前会议建模: + +- `RTC` 负责房间、入会 token、录制与 webhook。 +- `ASR` 负责实时字幕输入;partial 只广播,final 才会落库。 +- `worker` 负责会后录制入库、纪要生成和失败重试。 +- 默认按 `LiveKit` 风格能力建模,但通过 `RtcProviderGateway` / `MeetingAsrGateway` 做了适配隔离。 ## 相关文档 diff --git a/app/app.vue b/app/app.vue index f7d28ba..b5c5927 100644 --- a/app/app.vue +++ b/app/app.vue @@ -1,9 +1,52 @@ diff --git a/app/assets/styles/design-tokens.css b/app/assets/styles/design-tokens.css new file mode 100644 index 0000000..d328d04 --- /dev/null +++ b/app/assets/styles/design-tokens.css @@ -0,0 +1,112 @@ +:root { + --wl-font-sans: "IBM Plex Sans", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", sans-serif; + --wl-font-mono: "IBM Plex Mono", "SFMono-Regular", "Consolas", monospace; + + --wl-text-caption: 12px; + --wl-text-body-sm: 13px; + --wl-text-body: 14px; + --wl-text-title: 16px; + --wl-text-heading: 20px; + --wl-text-display: 28px; + + --wl-leading-tight: 1.35; + --wl-leading-normal: 1.5; + --wl-leading-relaxed: 1.65; + + --wl-space-1: 4px; + --wl-space-2: 8px; + --wl-space-3: 12px; + --wl-space-4: 16px; + --wl-space-5: 20px; + --wl-space-6: 24px; + --wl-space-8: 32px; + + --wl-radius-sm: 8px; + --wl-radius-md: 12px; + --wl-radius-lg: 16px; + --wl-radius-xl: 20px; + --wl-radius-pill: 999px; + + --wl-bg: #f5f7fb; + --wl-surface: #ffffff; + --wl-surface-muted: #f8fafc; + --wl-surface-soft: #eef4ff; + --wl-border: #d9e1ef; + --wl-border-strong: #c7d2e3; + --wl-text-primary: #0f172a; + --wl-text-secondary: #475569; + --wl-text-tertiary: #64748b; + --wl-text-faint: #94a3b8; + + --wl-primary-050: #eff4ff; + --wl-primary-100: #dbe7ff; + --wl-primary-500: #2f6af2; + --wl-primary-600: #1152d4; + --wl-primary-700: #0d43ad; + + --wl-success-050: #f0fdf4; + --wl-success-200: #bbf7d0; + --wl-success-700: #15803d; + --wl-warning-050: #fffbeb; + --wl-warning-200: #fde68a; + --wl-warning-700: #a16207; + --wl-danger-050: #fff1f2; + --wl-danger-200: #fecdd3; + --wl-danger-700: #be123c; + + --wl-shadow-card: 0 10px 28px rgba(15, 23, 42, 0.05); + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +html, +body, +#__nuxt { + margin: 0; + padding: 0; + height: 100%; + min-height: 100%; +} + +html { + background: var(--wl-bg); +} + +body { + background: var(--wl-bg); + color: var(--wl-text-primary); + font-family: var(--wl-font-sans); + font-size: var(--wl-text-body); + line-height: var(--wl-leading-normal); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +a { + color: inherit; + text-decoration: none; +} + +code, +pre, +kbd, +samp { + font-family: var(--wl-font-mono); +} + +html.wl-scroll-lock, +body.wl-scroll-lock { + height: 100dvh; + max-height: 100dvh; + overflow: hidden !important; + overscroll-behavior: none; +} + +body.wl-scroll-lock #__nuxt { + height: 100%; + max-height: 100%; + overflow: hidden; +} diff --git a/app/assets/styles/ui-primitives.css b/app/assets/styles/ui-primitives.css new file mode 100644 index 0000000..eaea17e --- /dev/null +++ b/app/assets/styles/ui-primitives.css @@ -0,0 +1,477 @@ +.wl-page-shell { + width: 100%; + max-width: 1320px; + margin: 0 auto; + padding: var(--wl-space-4); + display: flex; + flex-direction: column; + gap: var(--wl-space-4); +} + +.wl-page-shell--compact { + max-width: 960px; +} + +.wl-page-shell--wide { + max-width: 1480px; +} + +.wl-page-shell--auth { + max-width: 540px; + min-height: 100vh; + justify-content: center; +} + +.wl-page-shell--gap-lg { + gap: var(--wl-space-6); +} + +.wl-page-shell--gap-xl { + gap: var(--wl-space-8); +} + +.wl-page-header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: var(--wl-space-3); +} + +.wl-page-header__content { + min-width: 0; + flex: 1; +} + +.wl-page-header__kicker { + margin: 0 0 var(--wl-space-2); + color: var(--wl-text-faint); + font-size: var(--wl-text-caption); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.wl-page-header__title { + margin: 0; + font-size: var(--wl-text-heading); + line-height: var(--wl-leading-tight); + font-weight: 700; + color: var(--wl-text-primary); +} + +.wl-page-header__description { + margin: var(--wl-space-2) 0 0; + color: var(--wl-text-secondary); + font-size: var(--wl-text-body); + line-height: var(--wl-leading-normal); +} + +.wl-page-header__meta { + margin: var(--wl-space-2) 0 0; + display: flex; + flex-wrap: wrap; + gap: var(--wl-space-2); + color: var(--wl-text-tertiary); + font-size: var(--wl-text-caption); +} + +.wl-page-header__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: var(--wl-space-2); +} + +.wl-section-card { + background: var(--wl-surface); + border: 1px solid var(--wl-border); + border-radius: var(--wl-radius-lg); + box-shadow: var(--wl-shadow-card); + padding: var(--wl-space-4); +} + +.wl-section-card--compact { + padding: var(--wl-space-3); +} + +.wl-section-card--muted { + background: var(--wl-surface-muted); +} + +.wl-section-card--danger { + background: var(--wl-danger-050); + border-color: var(--wl-danger-200); +} + +.wl-section-card--success { + background: var(--wl-success-050); + border-color: var(--wl-success-200); +} + +.wl-section-card__head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--wl-space-3); + margin-bottom: var(--wl-space-3); +} + +.wl-section-card__title { + margin: 0; + color: var(--wl-text-primary); + font-size: var(--wl-text-title); + font-weight: 700; +} + +.wl-section-card__description { + margin: var(--wl-space-1) 0 0; + color: var(--wl-text-secondary); + font-size: var(--wl-text-body-sm); +} + +.wl-action-bar, +.wl-filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--wl-space-2); +} + +.wl-filter-bar { + padding: var(--wl-space-3); + background: var(--wl-surface-muted); + border: 1px solid var(--wl-border); + border-radius: var(--wl-radius-md); +} + +.wl-pill-tabs { + display: flex; + flex-wrap: wrap; + gap: var(--wl-space-2); +} + +.wl-pill-tab { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 0 14px; + border: 1px solid var(--wl-border); + border-radius: var(--wl-radius-pill); + background: var(--wl-surface-muted); + color: var(--wl-text-secondary); + font-size: var(--wl-text-caption); + font-weight: 600; + transition: + border-color 0.18s ease, + background-color 0.18s ease, + color 0.18s ease; +} + +.wl-pill-tab:hover { + border-color: var(--wl-border-strong); + background: var(--wl-surface); + color: var(--wl-text-primary); +} + +.wl-pill-tab--active { + border-color: var(--wl-text-primary); + background: var(--wl-text-primary); + color: #fff; +} + +.wl-state-block { + padding: var(--wl-space-4); + border: 1px solid var(--wl-border); + border-radius: var(--wl-radius-md); + background: var(--wl-surface); + color: var(--wl-text-secondary); +} + +.wl-state-block--center { + text-align: center; +} + +.wl-state-block--loading { + background: var(--wl-surface-muted); +} + +.wl-state-block--success { + background: var(--wl-success-050); + border-color: var(--wl-success-200); + color: var(--wl-success-700); +} + +.wl-state-block--warning { + background: var(--wl-warning-050); + border-color: var(--wl-warning-200); + color: var(--wl-warning-700); +} + +.wl-state-block--error { + background: var(--wl-danger-050); + border-color: var(--wl-danger-200); + color: var(--wl-danger-700); +} + +.wl-state-block__title { + margin: 0; + font-size: var(--wl-text-title); + font-weight: 700; + color: inherit; +} + +.wl-state-block__description { + margin: var(--wl-space-2) 0 0; + font-size: var(--wl-text-body-sm); + line-height: var(--wl-leading-normal); + color: inherit; +} + +.wl-inline-notice { + padding: var(--wl-space-3); + border: 1px solid var(--wl-border); + border-radius: var(--wl-radius-md); + font-size: var(--wl-text-body-sm); + line-height: var(--wl-leading-normal); +} + +.wl-inline-notice--success { + background: var(--wl-success-050); + border-color: var(--wl-success-200); + color: var(--wl-success-700); +} + +.wl-inline-notice--error { + background: var(--wl-danger-050); + border-color: var(--wl-danger-200); + color: var(--wl-danger-700); +} + +.wl-auth-shell { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--wl-space-6) var(--wl-space-4); +} + +.wl-auth-card { + width: min(100%, 520px); + display: flex; + flex-direction: column; + gap: var(--wl-space-4); +} + +.wl-text-meta { + color: var(--wl-text-tertiary); + font-size: var(--wl-text-caption); + line-height: var(--wl-leading-normal); +} + +.wl-text-muted { + color: var(--wl-text-secondary); +} + +.wl-button-reset { + border: none; + background: transparent; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; +} + +.wl-context-menu { + position: fixed; + z-index: 260; + min-width: var(--wl-context-menu-min-width, 228px); + max-width: min(320px, calc(100vw - 24px)); + padding: var(--wl-context-menu-padding, 8px); + border: 1px solid var(--wl-border); + border-radius: var(--wl-radius-xl); + background: var(--wl-surface); + box-shadow: var(--wl-shadow-card); + transform-origin: top left; + animation: wl-context-menu-enter 0.16s ease; +} + +.wl-context-menu__item { + width: 100%; + min-height: var(--wl-context-menu-item-min-height, 44px); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--wl-context-menu-item-gap, var(--wl-space-4)); + padding: 0 var(--wl-context-menu-item-padding-x, 14px); + border: 0; + border-radius: var(--wl-radius-md); + background: transparent; + color: var(--wl-text-secondary); + font-size: var(--wl-context-menu-label-size, var(--wl-text-body)); + line-height: var(--wl-leading-tight); + text-align: left; + transition: + background-color 0.18s ease, + color 0.18s ease; +} + +.wl-context-menu__item:hover:enabled, +.wl-context-menu__item:focus-visible { + background: var(--wl-surface-muted); + color: var(--wl-text-primary); + outline: none; +} + +.wl-context-menu__item:disabled { + color: var(--wl-text-faint); + cursor: not-allowed; +} + +.wl-context-menu__item--danger { + color: var(--wl-danger-700); +} + +.wl-context-menu__item--danger:hover:enabled, +.wl-context-menu__item--danger:focus-visible { + background: var(--wl-danger-050); + color: var(--wl-danger-700); +} + +.wl-context-menu__item-main { + min-width: 0; + display: inline-flex; + align-items: center; + gap: var(--wl-context-menu-item-main-gap, 12px); +} + +.wl-context-menu__icon { + flex: 0 0 auto; + font-size: var(--wl-context-menu-icon-size, 20px); + line-height: 1; +} + +.wl-context-menu__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wl-context-menu__shortcut { + flex: 0 0 auto; + color: var(--wl-text-faint); + font-size: var(--wl-context-menu-shortcut-size, var(--wl-text-caption)); + font-family: var(--wl-font-mono); +} + +.wl-context-menu__divider { + height: 1px; + margin: var(--wl-context-menu-divider-margin-y, 6px) var(--wl-context-menu-divider-margin-x, 10px); + background: var(--wl-border); +} + +@keyframes wl-context-menu-enter { + from { + opacity: 0; + transform: translateY(6px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +:where(.arco-btn, .arco-input-wrapper, .arco-textarea-wrapper, .arco-select-view, .arco-picker, .arco-input-number) { + font-family: var(--wl-font-sans); +} + +:where(.arco-btn-size-small, .arco-btn-size-mini, .arco-select-view-size-small, .arco-picker-size-small, .arco-input-size-small, .arco-input-number-size-small) { + font-size: var(--wl-text-body-sm); +} + +:where(.arco-input-wrapper, .arco-textarea-wrapper, .arco-select-view, .arco-picker, .arco-input-number, .arco-btn) { + border-radius: var(--wl-radius-md); +} + +:where(.arco-btn-primary) { + background: var(--wl-primary-600); + border-color: var(--wl-primary-600); +} + +:where(.arco-btn-primary):hover, +:where(.arco-btn-primary):focus, +:where(.arco-btn-primary):active { + background: var(--wl-primary-500); + border-color: var(--wl-primary-500); +} + +:where(.workspace-left-dock, .workspace-main-panel, .workspace-right-sidebar, .workspace-status-shell) { + font-family: var(--wl-font-sans); + color: var(--wl-text-secondary); +} + +.workspace-main-panel { + background: var(--wl-surface-muted); +} + +:where(.workspace-left-dock, .workspace-right-sidebar, .workspace-status-shell) { + background: var(--wl-surface); +} + +:where(.workspace-left-dock, .workspace-main-panel, .workspace-right-sidebar, .workspace-status-shell) :where(button, input, textarea, select) { + font-family: inherit; +} + +:where(.workspace-left-dock, .workspace-main-panel, .workspace-right-sidebar, .workspace-status-shell) :where(.text-\[10px\], .text-\[11px\]) { + font-size: var(--wl-text-caption) !important; + line-height: var(--wl-leading-normal) !important; +} + +:where(.workspace-left-dock, .workspace-main-panel, .workspace-right-sidebar, .workspace-status-shell) :where( + [class*="__meta"], + [class*="__hint"], + [class*="__status"], + [class*="__count"], + [class*="__line"], + [class*="__detail"], + [class*="__subtitle"], + [class*="__label"], + [class*="__value"], + .workspace-empty-text, + .workspace-log-text, + .workspace-config-summary +) { + font-size: var(--wl-text-caption) !important; + line-height: var(--wl-leading-normal); +} + +:where(.workspace-left-dock, .workspace-main-panel, .workspace-right-sidebar, .workspace-status-shell) :where( + [class*="__title"], + [class*="__name"], + .workspace-tree-block__title, + .workspace-card h3 +) { + color: var(--wl-text-primary); +} + +:where(.workspace-left-dock, .workspace-main-panel, .workspace-right-sidebar, .workspace-status-shell) :where( + .workspace-btn, + .workspace-pill, + .workspace-inline-action, + .workspace-upload-task-row__action, + .workspace-upload-drawer__action, + .workspace-mode-select +) { + font-size: var(--wl-text-caption); +} + +@media (min-width: 768px) { + .wl-page-shell { + padding-inline: var(--wl-space-6); + } +} diff --git a/app/assets/styles/user-settings.css b/app/assets/styles/user-settings.css new file mode 100644 index 0000000..9a51869 --- /dev/null +++ b/app/assets/styles/user-settings.css @@ -0,0 +1,787 @@ +.user-settings-nav { + padding: 0 14px 14px; + display: flex; + flex: 1; + flex-direction: column; + gap: 12px; + overflow-y: auto; +} + +.user-settings-nav-group { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 6px; +} + +.user-settings-nav-group__label { + color: var(--wl-text-tertiary); + font-size: var(--wl-text-caption); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.user-settings-nav-group__tabs { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; + overflow: visible; +} + +.user-settings-panel { + border-top: 1px solid var(--wl-border); +} + +.user-settings-panel--stack { + display: flex; + flex-direction: column; + gap: 14px; + border-top: none; +} + +.user-settings-row { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px 0; + border-bottom: 1px solid var(--wl-border); +} + +.user-settings-row:last-child { + border-bottom: none; +} + +.user-settings-row__heading { + flex: none; + min-width: 0; +} + +.user-settings-row__title { + color: var(--wl-text-primary); + font-size: var(--wl-text-body); + font-weight: 600; + line-height: var(--wl-leading-normal); +} + +.user-settings-row__desc { + display: none; +} + +.user-settings-row__content { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + align-items: flex-start; + gap: 8px; + text-align: left; +} + +.user-settings-row__content > div { + width: 100%; +} + +.user-settings-row__content--start, +.user-settings-row__content--overview, +.user-settings-row__content--profile { + align-items: flex-start; + text-align: left; +} + +.user-settings-row__content--profile { + align-items: stretch; +} + +.user-settings-inline-value { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 6px; +} + +.user-settings-profile-card { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.user-settings-profile-card--profile { + align-items: center; +} + +.user-settings-profile-section, +.user-settings-profile-content, +.user-settings-profile-editor, +.user-settings-profile-editor__hint, +.user-settings-member-list, +.user-settings-record-list, +.user-settings-usage-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.user-settings-profile-editor { + gap: 16px; + margin-top: 16px; +} + +.user-settings-profile-card__main, +.user-settings-profile-editor__preview { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 14px; +} + +.user-settings-profile-meta { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 6px; +} + +.user-settings-profile-card__footer, +.user-settings-profile-editor__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + justify-content: flex-end; +} + +.user-settings-profile-card__footer { + flex: 0 0 auto; +} + +.user-settings-avatar { + position: relative; + display: flex; + height: 52px; + width: 52px; + overflow: hidden; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 16px; + background: var(--wl-text-primary); + color: var(--wl-surface); + font-size: 20px; + font-weight: 600; +} + +.user-settings-avatar--large { + height: 68px; + width: 68px; + border-radius: 20px; + font-size: 24px; +} + +.user-settings-avatar--editor { + height: 84px; + width: 84px; + border-radius: 24px; + font-size: 28px; +} + +.user-settings-avatar__image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.user-settings-identity-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.user-settings-identity-item { + display: flex; + min-width: 0; + color: var(--wl-text-tertiary); + font-size: var(--wl-text-body-sm); + line-height: var(--wl-leading-normal); +} + +.user-settings-identity-item__value { + min-width: 0; + color: var(--wl-text-secondary); + word-break: break-all; +} + +.user-settings-identity-item__value--mono, +.user-settings-overview-code { + color: var(--wl-text-secondary); + font-family: var(--wl-font-mono); +} + +.user-settings-chip { + display: inline-flex; + align-items: center; + border: 1px solid var(--wl-border); + border-radius: var(--wl-radius-pill); + background: var(--wl-surface-muted); + padding: 3px 9px; + color: var(--wl-text-secondary); + font-size: var(--wl-text-caption); + font-weight: 600; +} + +.user-settings-chip--strong { + border-color: var(--wl-border-strong); + background: var(--wl-primary-050); + color: var(--wl-primary-700); +} + +.user-settings-chip--success { + border-color: var(--wl-success-200); + background: var(--wl-success-050); + color: var(--wl-success-700); +} + +.user-settings-chip--muted { + border-color: var(--wl-border); + background: var(--wl-surface-muted); + color: var(--wl-text-tertiary); +} + +.user-settings-overview-row { + display: flex; + width: 100%; + max-width: none; + flex-direction: column; + gap: 6px; + margin-left: 0; +} + +.user-settings-overview-row__main { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +.user-settings-overview-value-group { + display: flex; + min-width: 0; + flex: 1; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.user-settings-overview-value, +.user-settings-mini-card__value { + color: var(--wl-text-primary); + font-size: var(--wl-text-body); + font-weight: 600; + line-height: var(--wl-leading-normal); +} + +.user-settings-overview-value--secondary { + color: var(--wl-text-tertiary); + font-weight: 400; +} + +.user-settings-overview-code { + font-size: var(--wl-text-body-sm); + line-height: 1.6; + word-break: break-all; +} + +.user-settings-overview-actions { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.user-settings-card { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + padding: 16px 18px; + border: 1px solid var(--wl-border); + border-radius: var(--wl-radius-xl); + background: var(--wl-surface); +} + +.user-settings-ai-headline { + color: var(--wl-text-primary); + font-size: 24px; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.02em; +} + +.user-settings-ai-unit { + color: var(--wl-text-tertiary); + font-size: var(--wl-text-body-sm); + font-weight: 500; + line-height: 1.4; + padding-bottom: 2px; +} + +.user-settings-plus-btn, +.user-settings-icon-btn, +.user-settings-btn { + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.user-settings-plus-btn { + height: 32px; + width: 32px; + border: 1px solid var(--wl-border-strong); + border-radius: 999px; + background: var(--wl-surface); + color: var(--wl-text-secondary); + font-size: 18px; + font-weight: 500; + line-height: 1; +} + +.user-settings-plus-btn:hover { + border-color: var(--wl-text-faint); + background: var(--wl-surface-muted); + color: var(--wl-text-primary); +} + +.user-settings-plus-btn:disabled, +.user-settings-btn:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.user-settings-metric-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.user-settings-metric-grid--profile { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.user-settings-mini-card { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; + padding: 12px 14px; + border: 1px solid var(--wl-border); + border-radius: var(--wl-radius-lg); + background: var(--wl-surface-muted); +} + +.user-settings-mini-card__label, +.user-settings-field__label { + color: var(--wl-text-secondary); + font-size: var(--wl-text-caption); + font-weight: 500; +} + +.user-settings-section-header { + display: flex; + width: 100%; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.user-settings-section-title { + margin: 0; + color: var(--wl-text-primary); + font-size: var(--wl-text-body); + font-weight: 600; + line-height: var(--wl-leading-normal); +} + +.user-settings-copy { + margin: 0; + color: var(--wl-text-secondary); + font-size: var(--wl-text-body-sm); + line-height: var(--wl-leading-normal); +} + +.user-settings-meta { + margin: 0; + color: var(--wl-text-tertiary); + font-size: var(--wl-text-caption); + line-height: var(--wl-leading-normal); +} + +.user-settings-meta--mono { + font-family: var(--wl-font-mono); +} + +.user-settings-name { + margin: 0; + color: var(--wl-text-primary); + font-size: var(--wl-text-body); + font-weight: 600; + line-height: var(--wl-leading-normal); +} + +.user-settings-section-header > div > p + p, +.user-settings-row__desc { + display: none; +} + +.user-settings-member-list, +.user-settings-record-list, +.user-settings-usage-list, +.user-settings-session-list { + width: 100%; +} + +.user-settings-member-item, +.user-settings-record-item, +.user-settings-usage-item, +.user-settings-session-item { + display: flex; + width: 100%; + align-items: flex-start; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--wl-border); + border-radius: var(--wl-radius-lg); + background: var(--wl-surface); +} + +.user-settings-usage-item { + flex-direction: column; +} + +.user-settings-member-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-left: auto; +} + +.user-settings-member-avatar { + display: flex; + height: 34px; + width: 34px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 10px; + background: var(--wl-primary-050); + color: var(--wl-primary-600); + font-size: var(--wl-text-body-sm); + font-weight: 600; +} + +.user-settings-progress-track { + position: relative; + overflow: hidden; + width: 100%; + height: 8px; + border-radius: 999px; + background: var(--wl-border); +} + +.user-settings-progress-fill { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--wl-primary-600) 0%, var(--wl-primary-500) 100%); +} + +.user-settings-pagination, +.user-settings-field, +.user-settings-inline-editor { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.user-settings-field, +.user-settings-inline-editor { + width: 100%; + flex-direction: column; +} + +.user-settings-input, +.user-settings-select { + height: 38px; + width: 100%; + border: 1px solid var(--wl-border-strong); + border-radius: var(--wl-radius-md); + background: var(--wl-surface); + padding: 0 12px; + color: var(--wl-text-primary); + font-size: var(--wl-text-body-sm); + outline: none; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.user-settings-input:focus, +.user-settings-select:focus { + border-color: var(--wl-primary-100); + box-shadow: 0 0 0 3px rgb(47 106 242 / 0.14); +} + +.user-settings-input:disabled, +.user-settings-select:disabled { + cursor: not-allowed; + background: var(--wl-surface-muted); + color: var(--wl-text-faint); +} + +.user-settings-select--compact { + min-width: 132px; + height: 30px; + padding: 0 28px 0 10px; + font-size: var(--wl-text-body-sm); + border-radius: 10px; +} + +.user-settings-link-card { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--wl-border); + border-radius: var(--wl-radius-lg); + background: var(--wl-surface-muted); +} + +.user-settings-empty { + width: 100%; + padding: 14px; + border: 1px dashed var(--wl-border-strong); + border-radius: var(--wl-radius-lg); + background: var(--wl-surface-muted); + color: var(--wl-text-tertiary); + font-size: var(--wl-text-body-sm); + line-height: var(--wl-leading-normal); +} + +.user-settings-feedback { + padding: 10px 12px; + border: 1px solid var(--wl-border); + border-radius: var(--wl-radius-lg); + font-size: var(--wl-text-body-sm); + line-height: var(--wl-leading-normal); +} + +.user-settings-feedback--danger { + border-color: var(--wl-danger-200); + background: var(--wl-danger-050); + color: var(--wl-danger-700); +} + +.user-settings-feedback--success { + border-color: var(--wl-success-200); + background: var(--wl-success-050); + color: var(--wl-success-700); +} + +.user-settings-tab { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + justify-content: flex-start; + gap: 6px; + border: 1px solid transparent; + border-radius: 10px; + background: transparent; + padding: 8px 9px; + color: var(--wl-text-secondary); + font-size: var(--wl-text-body-sm); + font-weight: 600; + line-height: 1.4; + transition: + background-color 0.15s ease, + color 0.15s ease, + border-color 0.15s ease; +} + +.user-settings-tab__label { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + word-break: keep-all; + writing-mode: horizontal-tb; + text-orientation: mixed; +} + +.user-settings-tab:hover { + background: var(--wl-surface); + color: var(--wl-text-primary); +} + +.user-settings-tab.is-active { + border-color: var(--wl-primary-100); + background: var(--wl-primary-050); + color: var(--wl-primary-600); +} + +.user-settings-btn { + border: 1px solid var(--wl-border-strong); + border-radius: var(--wl-radius-md); + background: var(--wl-surface); + padding: 0 12px; + height: 34px; + color: var(--wl-text-secondary); + font-size: var(--wl-text-body-sm); + font-weight: 600; +} + +.user-settings-btn:hover:enabled, +.user-settings-icon-btn:hover { + border-color: var(--wl-text-faint); + background: var(--wl-surface-muted); +} + +.user-settings-btn--primary { + border-color: var(--wl-primary-100); + color: var(--wl-primary-600); +} + +.user-settings-btn--primary:hover:enabled { + background: var(--wl-primary-050); + border-color: var(--wl-primary-500); +} + +.user-settings-btn--danger { + border-color: var(--wl-danger-200); + color: var(--wl-danger-700); +} + +.user-settings-btn--danger:hover:enabled { + background: var(--wl-danger-050); +} + +.user-settings-btn--compact { + height: 30px; + padding: 0 10px; + font-size: var(--wl-text-body-sm); +} + +.user-settings-icon-btn { + height: 30px; + width: 30px; + border: 1px solid var(--wl-border); + border-radius: 10px; + background: var(--wl-surface); + color: var(--wl-text-secondary); +} + +@media (min-width: 1024px) { + .user-settings-tab { + width: 100%; + min-width: 0; + } +} + +@media (max-width: 1023px) { + .user-settings-nav { + flex-direction: column; + gap: 12px; + padding: 0 14px 14px; + } + + .user-settings-nav-group { + flex: none; + } + + .user-settings-nav-group__tabs { + flex-direction: row; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + } + + .user-settings-panel--stack, + .user-settings-row { + gap: 12px; + } + + .user-settings-card, + .user-settings-profile-card, + .user-settings-section-header, + .user-settings-record-item, + .user-settings-member-item { + flex-direction: column; + } + + .user-settings-avatar { + align-self: flex-start; + } + + .user-settings-profile-card__main, + .user-settings-profile-card__footer { + width: 100%; + } + + .user-settings-member-actions, + .user-settings-pagination { + justify-content: flex-start; + margin-left: 0; + } + + .user-settings-metric-grid { + grid-template-columns: minmax(0, 1fr); + } + + .user-settings-nav-group__tabs, + .user-settings-tab { + white-space: nowrap; + } + + .user-settings-tab { + width: auto; + min-width: max-content; + flex: 0 0 auto; + } + + .user-settings-nav-group__tabs { + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + padding-bottom: 2px; + } + + .user-settings-overview-row__main { + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 639px) { + .user-settings-card { + padding: 14px; + border-radius: 18px; + } + + .user-settings-ai-headline { + font-size: 22px; + } + + .user-settings-mini-card__value { + font-size: var(--wl-text-body-sm); + } +} diff --git a/app/assets/styles/workspace-left-sidebar.css b/app/assets/styles/workspace-left-sidebar.css new file mode 100644 index 0000000..a7791f0 --- /dev/null +++ b/app/assets/styles/workspace-left-sidebar.css @@ -0,0 +1,1897 @@ +.workspace-left-dock { + --workspace-left-section-padding-x: 12px; + --workspace-left-section-title-padding-y: 4px; + --workspace-left-section-title-padding-left: 8px; + --workspace-left-section-title-action-size: 22px; + --workspace-left-section-title-row-padding-right: 8px; + --workspace-left-tree-row-min-height: 36px; + --workspace-left-tree-row-padding-left: 10px; + --workspace-left-tree-row-padding-right: 34px; + --workspace-left-tree-gap: 8px; + --workspace-left-tree-root-offset: 6px; + --workspace-left-tree-expander-size: 20px; + --workspace-left-tree-expander-icon-size: 16px; + --workspace-left-tree-expander-margin-left: 4px; + --workspace-left-tree-indent-step: 14px; + --workspace-left-tree-dropzone-height: 6px; + --workspace-left-tree-dropzone-offset-y: 3px; + --workspace-left-tree-tail-margin-x: 12px; + --workspace-left-tree-tail-padding-y: 6px; + --workspace-left-tree-tail-padding-x: 8px; + --workspace-left-resource-action-size: 22px; + --workspace-left-resource-action-right: 8px; + --workspace-left-upload-row-min-height: 48px; + --workspace-left-upload-row-padding-y: 6px; + --workspace-left-upload-row-padding-x: 12px; + --workspace-left-upload-action-height: 22px; + --workspace-left-upload-action-padding-x: 7px; + --workspace-left-recycle-row-padding-y: 6px; + --workspace-left-recycle-row-padding-x: 12px; + --workspace-left-recycle-action-height: 22px; + --workspace-left-recycle-action-min-width: 52px; + --workspace-left-recycle-action-padding-x: 7px; + --workspace-left-library-row-padding-y: 6px; + --workspace-left-library-row-padding-x: 12px; + --workspace-left-library-action-height: 26px; + --workspace-left-library-action-min-width: 48px; + border-right: 1px solid #d3d8e4; + background: #ffffff; + display: flex; + flex-shrink: 0; + min-height: 0; + overflow: hidden; + width: 100%; +} + +.workspace-left-dock--compact { + --workspace-left-section-padding-x: 10px; + --workspace-left-section-title-padding-y: 3px; + --workspace-left-section-title-padding-left: 6px; + --workspace-left-section-title-action-size: 20px; + --workspace-left-section-title-row-padding-right: 6px; + --workspace-left-tree-row-min-height: 32px; + --workspace-left-tree-row-padding-left: 8px; + --workspace-left-tree-row-padding-right: 30px; + --workspace-left-tree-gap: 7px; + --workspace-left-tree-root-offset: 4px; + --workspace-left-tree-expander-size: 18px; + --workspace-left-tree-expander-icon-size: 14px; + --workspace-left-tree-expander-margin-left: 2px; + --workspace-left-tree-indent-step: 12px; + --workspace-left-tree-dropzone-height: 5px; + --workspace-left-tree-dropzone-offset-y: 2px; + --workspace-left-tree-tail-margin-x: 10px; + --workspace-left-tree-tail-padding-y: 5px; + --workspace-left-tree-tail-padding-x: 7px; + --workspace-left-resource-action-size: 20px; + --workspace-left-resource-action-right: 6px; + --workspace-left-upload-row-min-height: 42px; + --workspace-left-upload-row-padding-y: 5px; + --workspace-left-upload-row-padding-x: 10px; + --workspace-left-upload-action-height: 20px; + --workspace-left-upload-action-padding-x: 6px; + --workspace-left-recycle-row-padding-y: 5px; + --workspace-left-recycle-row-padding-x: 10px; + --workspace-left-recycle-action-height: 20px; + --workspace-left-recycle-action-min-width: 48px; + --workspace-left-recycle-action-padding-x: 6px; + --workspace-left-library-row-padding-y: 5px; + --workspace-left-library-row-padding-x: 10px; + --workspace-left-library-action-height: 24px; + --workspace-left-library-action-min-width: 44px; +} + +.workspace-left-dock--default { + --workspace-left-section-padding-x: 12px; + --workspace-left-section-title-padding-y: 4px; + --workspace-left-section-title-padding-left: 8px; + --workspace-left-section-title-action-size: 22px; + --workspace-left-section-title-row-padding-right: 8px; + --workspace-left-tree-row-min-height: 36px; + --workspace-left-tree-row-padding-left: 10px; + --workspace-left-tree-row-padding-right: 34px; + --workspace-left-tree-gap: 8px; + --workspace-left-tree-root-offset: 6px; + --workspace-left-tree-expander-size: 20px; + --workspace-left-tree-expander-icon-size: 16px; + --workspace-left-tree-expander-margin-left: 4px; + --workspace-left-tree-indent-step: 14px; + --workspace-left-tree-dropzone-height: 6px; + --workspace-left-tree-dropzone-offset-y: 3px; + --workspace-left-tree-tail-margin-x: 12px; + --workspace-left-tree-tail-padding-y: 6px; + --workspace-left-tree-tail-padding-x: 8px; + --workspace-left-resource-action-size: 22px; + --workspace-left-resource-action-right: 8px; + --workspace-left-upload-row-min-height: 48px; + --workspace-left-upload-row-padding-y: 6px; + --workspace-left-upload-row-padding-x: 12px; + --workspace-left-upload-action-height: 22px; + --workspace-left-upload-action-padding-x: 7px; + --workspace-left-recycle-row-padding-y: 6px; + --workspace-left-recycle-row-padding-x: 12px; + --workspace-left-recycle-action-height: 22px; + --workspace-left-recycle-action-min-width: 52px; + --workspace-left-recycle-action-padding-x: 7px; + --workspace-left-library-row-padding-y: 6px; + --workspace-left-library-row-padding-x: 12px; + --workspace-left-library-action-height: 26px; + --workspace-left-library-action-min-width: 48px; +} + +.workspace-left-dock--relaxed { + --workspace-left-section-padding-x: 14px; + --workspace-left-section-title-padding-y: 5px; + --workspace-left-section-title-padding-left: 10px; + --workspace-left-section-title-action-size: 24px; + --workspace-left-section-title-row-padding-right: 10px; + --workspace-left-tree-row-min-height: 40px; + --workspace-left-tree-row-padding-left: 12px; + --workspace-left-tree-row-padding-right: 38px; + --workspace-left-tree-gap: 9px; + --workspace-left-tree-root-offset: 8px; + --workspace-left-tree-expander-size: 22px; + --workspace-left-tree-expander-icon-size: 18px; + --workspace-left-tree-expander-margin-left: 5px; + --workspace-left-tree-indent-step: 18px; + --workspace-left-tree-dropzone-height: 7px; + --workspace-left-tree-dropzone-offset-y: 3px; + --workspace-left-tree-tail-margin-x: 14px; + --workspace-left-tree-tail-padding-y: 7px; + --workspace-left-tree-tail-padding-x: 10px; + --workspace-left-resource-action-size: 24px; + --workspace-left-resource-action-right: 10px; + --workspace-left-upload-row-min-height: 52px; + --workspace-left-upload-row-padding-y: 7px; + --workspace-left-upload-row-padding-x: 14px; + --workspace-left-upload-action-height: 24px; + --workspace-left-upload-action-padding-x: 8px; + --workspace-left-recycle-row-padding-y: 7px; + --workspace-left-recycle-row-padding-x: 14px; + --workspace-left-recycle-action-height: 24px; + --workspace-left-recycle-action-min-width: 56px; + --workspace-left-recycle-action-padding-x: 8px; + --workspace-left-library-row-padding-y: 7px; + --workspace-left-library-row-padding-x: 14px; + --workspace-left-library-action-height: 28px; + --workspace-left-library-action-min-width: 52px; +} + +@media (min-width: 1280px) { + .workspace-left-dock { + width: 362px; + } +} + +.workspace-left-panel { + background: #ffffff; + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + min-width: 0; +} + +.workspace-left-panel__feature { + display: flex; + flex: 1; + flex-direction: column; + height: 100%; + min-height: 0; + min-width: 0; +} + +.workspace-left-panel__body { + display: flex; + flex-direction: column; + padding: 8px 0 12px; + overflow-y: auto; + flex: 1; + height: 100%; + min-height: 0; + min-width: 0; +} + +.workspace-resource-manager-panel__body { + padding-top: 0; + overflow-x: hidden; +} + +.workspace-left-panel__footer { + padding: 8px 12px 12px; + border-top: 1px solid #e2e8f2; + background: #ffffff; + flex-shrink: 0; +} + +.workspace-tree-block { + margin-bottom: 8px; + min-width: 0; +} + +.workspace-tree-block--recycle-panel { + margin-bottom: 0; +} + +.workspace-recycle-panel__header { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 14px 0; + color: #5b6f92; +} + +.workspace-recycle-panel__header .material-symbols-outlined { + font-size: 18px; +} + +.workspace-recycle-panel__header h3 { + margin: 0; + font-size: 13px; + font-weight: 700; +} + +.workspace-recycle-panel__hint { + margin: 4px 14px 8px; + font-size: 11px; + line-height: 1.4; + color: #8b98ad; +} + +.workspace-tree-block__title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: calc(var(--workspace-left-section-title-action-size) + var(--workspace-left-section-title-padding-y, 4px) * 2); + min-width: 0; + padding-right: var(--workspace-left-section-title-row-padding-right); +} + +.workspace-tree-block__title-row--sticky, +.workspace-tree-block__title--sticky { + position: sticky; + top: 0; + z-index: 8; + background: #ffffff; +} + +.workspace-project-add-actions { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.workspace-project-add-actions__input { + display: none; +} + +.workspace-tree-block__title { + width: 100%; + border: none; + background: transparent; + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + color: #7888a2; + padding: + var(--workspace-left-section-title-padding-y, 4px) + var(--workspace-left-section-padding-x) + var(--workspace-left-section-title-padding-y, 4px) + var(--workspace-left-section-title-padding-left, var(--workspace-left-section-padding-x)); + min-height: calc(var(--workspace-left-section-title-action-size) + var(--workspace-left-section-title-padding-y, 4px) * 2); + text-transform: uppercase; + text-align: left; + cursor: pointer; +} + +.workspace-tree-block__title-row > .workspace-tree-block__title { + width: auto; + min-width: 0; + flex: 1 1 auto; +} + +.workspace-tree-block__title:hover { + color: #556888; +} + +.workspace-tree-block__title .material-symbols-outlined { + font-size: 18px; + transition: transform 0.2s ease; +} + +.workspace-tree-block__arrow--collapsed { + transform: rotate(-90deg); +} + +.workspace-tree-block__title-action { + border: 1px solid #d3dbe8; + background: #ffffff; + color: #43629c; + width: var(--workspace-left-section-title-action-size); + height: var(--workspace-left-section-title-action-size); + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; +} + +.workspace-tree-block__title-action:hover:enabled { + background: #edf3ff; +} + +.workspace-tree-block__title-action--active { + background: #edf3ff; + border-color: #b9ccee; + color: #31518e; +} + +.workspace-tree-block__title-action:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.workspace-tree-block__title-action .material-symbols-outlined { + font-size: 16px; +} + +.workspace-project-add-actions__menu { + position: absolute; + top: 28px; + right: 0; + min-width: 160px; + border: 1px solid #d6deec; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 12px 24px rgba(31, 45, 70, 0.14); + padding: 4px; + z-index: 24; +} + +.workspace-project-add-actions__menu-item { + width: 100%; + border: none; + border-radius: 6px; + background: transparent; + color: #475977; + min-height: 28px; + font-size: 11px; + text-align: left; + padding: 5px 8px; + cursor: pointer; +} + +.workspace-project-add-actions__menu-item:hover:enabled { + background: #edf2fb; +} + +.workspace-project-add-actions__menu-item:disabled { + opacity: 0.58; + cursor: not-allowed; +} + +.workspace-project-add-actions__divider { + height: 1px; + background: #e6ecf6; + margin: 4px 6px; +} + +.workspace-tree-block__content { + min-width: 0; + padding-bottom: 6px; +} + +.workspace-resource-batch-toolbar { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px var(--workspace-left-section-padding-x) 8px; +} + +.workspace-resource-batch-toolbar__summary { + font-size: 11px; + font-weight: 600; + color: #5b6f92; +} + +.workspace-resource-batch-toolbar__actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.workspace-resource-batch-toolbar__action { + min-height: 24px; + padding: 0 8px; + border: 1px solid #d7e2f0; + border-radius: 6px; + background: #ffffff; + color: #45608c; + font-size: 10px; + cursor: pointer; +} + +.workspace-resource-batch-toolbar__action:hover:enabled { + background: #eef4ff; +} + +.workspace-resource-batch-toolbar__action--danger { + color: #c74747; + border-color: #efc6c6; +} + +.workspace-resource-batch-toolbar__action--danger:hover:enabled { + background: #fff3f3; +} + +.workspace-resource-batch-toolbar__action:disabled { + opacity: 0.58; + cursor: not-allowed; +} + +.workspace-skeleton { + position: relative; + overflow: hidden; + border-radius: 6px; + background: #e7edf7; +} + +.workspace-skeleton::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.82), transparent); + animation: workspace-skeleton-shimmer 1.2s ease-in-out infinite; +} + +@keyframes workspace-skeleton-shimmer { + 100% { + transform: translateX(100%); + } +} + +.workspace-tree-item-row { + position: relative; + display: flex; + align-items: center; + min-width: 0; + width: 100%; + transition: background-color 0.2s ease; +} + +.workspace-tree-item-row:hover { + background: #f3f6fb; +} + +.workspace-tree-item-row--active { + background: #edf3ff; +} + +.workspace-tree-item-row--batch-selected { + background: #edf3ff; +} + +.workspace-tree-item-row--drop-inside { + background: rgba(47, 106, 242, 0.08); +} + +.workspace-resource-tree-entry { + position: relative; + display: flex; + flex-direction: column; +} + +.workspace-resource-tree-row__main { + box-sizing: border-box; + min-width: 0; + flex: 1; + display: flex; + align-items: center; +} + +.workspace-resource-tree-row__main--with-actions { + padding-right: calc(var(--workspace-left-resource-action-size) + var(--workspace-left-resource-action-right) + 8px); +} + +.workspace-tree-item__checkbox { + width: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.workspace-tree-item__checkbox-input { + width: 14px; + height: 14px; + margin: 0; + accent-color: #3f6ae0; + cursor: pointer; +} + +.workspace-tree-item__expander { + width: var(--workspace-left-tree-expander-size); + height: var(--workspace-left-tree-expander-size); + margin-left: var(--workspace-left-tree-expander-margin-left); + border: none; + border-radius: 6px; + background: transparent; + color: #7f8ba0; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + cursor: pointer; +} + +.workspace-tree-item__expander .material-symbols-outlined { + font-size: var(--workspace-left-tree-expander-icon-size, 16px); +} + +.workspace-tree-item__expander:hover:enabled { + background: #eef3fb; +} + +.workspace-tree-item__expander--placeholder { + cursor: default; + opacity: 0; +} + +.workspace-tree-dropzone { + height: var(--workspace-left-tree-dropzone-height); + margin-top: calc(var(--workspace-left-tree-dropzone-offset-y) * -1); + margin-bottom: calc(var(--workspace-left-tree-dropzone-offset-y) * -1); + margin-right: 8px; + border-radius: 999px; + transition: background-color 0.16s ease; +} + +.workspace-tree-dropzone--active { + background: rgba(47, 106, 242, 0.25); +} + +.workspace-tree-dropzone--tail { + margin: 4px var(--workspace-left-tree-tail-margin-x) 0; + height: auto; + padding: var(--workspace-left-tree-tail-padding-y) var(--workspace-left-tree-tail-padding-x); + border: 1px dashed #d6deec; + color: #6e7f9b; + font-size: 10px; + text-align: center; +} + +.workspace-tree-item { + width: 100%; + border: none; + background: transparent; + display: flex; + align-items: center; + gap: var(--workspace-left-tree-gap); + min-height: var(--workspace-left-tree-row-min-height); + padding: 0 var(--workspace-left-tree-row-padding-right) 0 var(--workspace-left-tree-row-padding-left); + color: #4f5f7f; + cursor: pointer; + text-align: left; + position: relative; + transition: background-color 0.2s ease; +} + +.workspace-resource-tree-row__main--with-actions .workspace-tree-item { + padding-right: 10px; +} + +.workspace-tree-item:hover { + background: transparent; +} + +.workspace-tree-item--active { + background: transparent; + color: #2f4368; +} + +.workspace-tree-item-row--skeleton .workspace-tree-item--skeleton { + cursor: default; + pointer-events: none; +} + +.workspace-tree-item--skeleton { + gap: 10px; + align-items: center; +} + +.workspace-tree-item__icon-skeleton { + width: 18px; + height: 18px; + border-radius: 4px; + flex-shrink: 0; +} + +.workspace-tree-item__content-skeleton { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 5px; +} + +.workspace-tree-item__label-skeleton { + height: 11px; + width: 78%; +} + +.workspace-tree-item__meta-skeleton { + height: 8px; + width: 48%; + border-radius: 999px; +} + +.workspace-tree-item__action-skeleton { + width: 18px; + height: 18px; + border-radius: 5px; + flex-shrink: 0; +} + +.workspace-tree-item-row--skeleton:nth-child(2n) .workspace-tree-item__label-skeleton { + width: 66%; +} + +.workspace-tree-item-row--skeleton:nth-child(3n) .workspace-tree-item__label-skeleton { + width: 72%; +} + +.workspace-tree-item-row--skeleton:nth-child(2n) .workspace-tree-item__meta-skeleton { + width: 40%; +} + +.workspace-tree-item__icon { + font-size: 17px; + width: 17px; + height: 17px; + line-height: 17px; +} + +.workspace-tree-item__label { + font-size: 11px; + line-height: 1.25; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.workspace-tree-item-row--upload + .workspace-tree-item-row--upload { + border-top: 1px solid #eef3fb; +} + +.workspace-upload-task-item { + min-height: var(--workspace-left-upload-row-min-height); + display: flex; + align-items: center; + gap: 8px; + padding: var(--workspace-left-upload-row-padding-y) var(--workspace-left-upload-row-padding-x); + border-radius: 0; + background: #f8fbff; +} + +.workspace-upload-task-item__content { + min-width: 0; + flex: 1; +} + +.workspace-upload-task-item__header { + display: flex; + align-items: center; + gap: 6px; +} + +.workspace-upload-task-item__status { + flex-shrink: 0; + font-size: 10px; + font-weight: 600; +} + +.workspace-upload-task-item__meta { + margin-top: 2px; + color: #8290a6; + font-size: 10px; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.workspace-upload-task-item__actions { + display: inline-flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.workspace-upload-task-item__action { + height: var(--workspace-left-upload-action-height); + padding: 0 var(--workspace-left-upload-action-padding-x); + border: 1px solid #d7e2f0; + border-radius: 6px; + background: #ffffff; + color: #45608c; + font-size: 10px; + cursor: pointer; +} + +.workspace-upload-task-item__action:hover { + background: #eef4ff; +} + +.workspace-upload-task-item__action--danger { + color: #c74747; + border-color: #efc6c6; +} + +.workspace-upload-task-item__action--danger:hover { + background: #fff3f3; +} + +.workspace-upload-ring { + --upload-progress: 0%; + width: 20px; + height: 20px; + position: relative; + border-radius: 999px; + flex-shrink: 0; + background: conic-gradient(#5b8dff var(--upload-progress), #dbe6f6 0); +} + +.workspace-upload-ring__core { + position: absolute; + inset: 4px; + border-radius: 999px; + background: #ffffff; +} + +.workspace-upload-ring--outline { + width: 18px; + height: 18px; +} + +.workspace-upload-ring--outline .workspace-upload-ring__core { + inset: 3px; +} + +.workspace-upload-ring--indeterminate { + background: transparent; + border: 2px solid #dbe6f6; + border-top-color: #5b8dff; + animation: workspace-upload-ring-spin 0.9s linear infinite; +} + +.workspace-upload-tone--active { + color: #31518e; +} + +.workspace-upload-tone--active .workspace-upload-task-item__status { + color: #3f6ae0; +} + +.workspace-upload-tone--paused { + background: #fffaf0; + color: #7f6330; +} + +.workspace-upload-tone--paused .workspace-upload-task-item__status { + color: #c68a11; +} + +.workspace-upload-tone--failed { + background: #fff6f6; + color: #884747; +} + +.workspace-upload-tone--failed .workspace-upload-task-item__status { + color: #d14f4f; +} + +.workspace-upload-tone--failed.workspace-upload-ring { + background: conic-gradient(#d14f4f var(--upload-progress), #f2d7d7 0); +} + +.workspace-upload-tone--paused.workspace-upload-ring { + background: conic-gradient(#d8a33b var(--upload-progress), #f4e5c5 0); +} + +.workspace-upload-tone--finalizing { + color: #31518e; +} + +.workspace-upload-tone--completed { + color: #357262; +} + +@keyframes workspace-upload-ring-spin { + to { + transform: rotate(360deg); + } +} + +.workspace-resource-actions { + position: absolute; + top: 50%; + right: var(--workspace-left-resource-action-right); + transform: translateY(-50%); + z-index: 10; +} + +.workspace-resource-actions__trigger { + width: var(--workspace-left-resource-action-size); + height: var(--workspace-left-resource-action-size); + border: none; + border-radius: 6px; + background: transparent; + color: #7f8ba0; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: + opacity 0.16s ease, + background-color 0.16s ease, + color 0.16s ease; +} + +.workspace-tree-item-row:hover .workspace-resource-actions__trigger, +.workspace-tree-item-row--menu-open .workspace-resource-actions__trigger { + opacity: 1; + pointer-events: auto; +} + +.workspace-resource-actions__trigger:hover:enabled { + background: #e9effa; + color: #3f5d96; +} + +.workspace-resource-actions__trigger:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.workspace-resource-actions__trigger .material-symbols-outlined { + font-size: 16px; +} + +.workspace-resource-actions__menu { + position: absolute; + top: 26px; + right: 0; + min-width: 136px; + border: 1px solid #d6deec; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 12px 24px rgba(31, 45, 70, 0.14); + padding: 4px; + z-index: 20; +} + +.workspace-resource-actions__divider { + height: 1px; + background: #e6ecf6; + margin: 4px 6px; +} + +.workspace-resource-actions__menu-item { + width: 100%; + border: none; + border-radius: 6px; + background: transparent; + color: #475977; + height: 28px; + font-size: 11px; + text-align: left; + padding: 0 8px; + cursor: pointer; +} + +.workspace-resource-actions__menu-item:hover:enabled { + background: #edf2fb; +} + +.workspace-resource-actions__menu-item--danger { + color: #cb3b3b; +} + +.workspace-resource-actions__menu-item--danger:hover:enabled { + background: #fff0f0; +} + +.workspace-resource-actions__menu-item:disabled { + opacity: 0.58; + cursor: not-allowed; +} + +.workspace-recycle-item { + display: flex; + align-items: center; + gap: 8px; + padding: var(--workspace-left-recycle-row-padding-y) var(--workspace-left-recycle-row-padding-x); +} + +.workspace-recycle-item:hover { + background: #f7f9fd; +} + +.workspace-recycle-item__content { + min-width: 0; + flex: 1; +} + +.workspace-recycle-item__title { + font-size: 11px; + color: #52617c; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.workspace-recycle-item__meta { + margin-top: 2px; + font-size: 10px; + color: #8d99ae; +} + +.workspace-recycle-item__actions { + display: inline-flex; + gap: 6px; + align-items: center; +} + +.workspace-recycle-item__action { + border: 1px solid #d4dce9; + border-radius: 6px; + background: #ffffff; + color: #4a5f84; + height: var(--workspace-left-recycle-action-height); + min-width: var(--workspace-left-recycle-action-min-width); + padding: 0 var(--workspace-left-recycle-action-padding-x); + font-size: 10px; + cursor: pointer; +} + +.workspace-recycle-item__action--ghost:hover:enabled { + background: #edf3ff; +} + +.workspace-recycle-item__action--danger { + color: #c74343; + border-color: #efc0c0; +} + +.workspace-recycle-item__action--danger:hover:enabled { + background: #fff4f4; +} + +.workspace-recycle-item__action:disabled { + opacity: 0.58; + cursor: not-allowed; +} + +.workspace-icon--doc { + color: #3d6cdd; +} + +.workspace-icon--table { + color: #7b879f; +} + +.workspace-icon--pdf { + color: #f04d4d; +} + +.workspace-icon--slide { + color: #f97316; +} + +.workspace-icon--text { + color: #0f766e; +} + +.workspace-icon--image { + color: #7c3aed; +} + +.workspace-icon--collab { + color: #0f766e; +} + +.workspace-library-skeleton-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 14px; +} + +.workspace-library-skeleton-item__left { + min-width: 0; + flex: 1; + display: flex; + align-items: center; + gap: 10px; +} + +.workspace-library-skeleton-item__icon { + width: 16px; + height: 16px; + border-radius: 4px; + flex-shrink: 0; +} + +.workspace-library-skeleton-item__content { + min-width: 0; + flex: 1; +} + +.workspace-library-skeleton-item__title { + height: 11px; + width: 64%; +} + +.workspace-library-skeleton-item__meta { + margin-top: 6px; + height: 8px; + width: 40%; + border-radius: 999px; +} + +.workspace-library-skeleton-item__action { + width: 36px; + height: 18px; + border-radius: 6px; + flex-shrink: 0; +} + +.workspace-library-skeleton-item:nth-child(2n) .workspace-library-skeleton-item__title { + width: 58%; +} + +.workspace-library-skeleton-item:nth-child(3n) .workspace-library-skeleton-item__meta { + width: 34%; +} + +.workspace-outline-skeleton-row { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 14px; +} + +.workspace-outline-skeleton-row--child { + padding-left: 36px; +} + +.workspace-outline-skeleton__dot { + width: 6px; + height: 6px; + border-radius: 999px; + flex-shrink: 0; +} + +.workspace-outline-skeleton { + height: 10px; + width: 76%; + border-radius: 999px; +} + +.workspace-outline-skeleton-row:nth-child(2n) .workspace-outline-skeleton { + width: 62%; +} + +.workspace-outline-skeleton-row:nth-child(3n) .workspace-outline-skeleton { + width: 70%; +} + +.workspace-outline-item { + width: 100%; + border: none; + background: transparent; + font-size: 13px; + line-height: 1.28; + text-align: left; + color: #6f7e98; + padding: 9px 14px; + cursor: pointer; + position: relative; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.workspace-outline-item--upload { + display: flex; + align-items: center; + gap: 10px; + justify-content: space-between; + cursor: default; +} + +.workspace-outline-item__content { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.workspace-outline-item__label { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.workspace-outline-item__meta { + font-size: 10px; + color: #8895aa; +} + +.workspace-outline-item:hover { + background: #f3f6fb; +} + +.workspace-outline-item--child { + padding-left: 36px; + font-size: 12px; +} + +.workspace-outline-item--active { + background: #edf3ff; + color: #1f2f4d; + font-weight: 600; +} + +.workspace-outline-item--active::before { + content: ''; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 3px; + border-radius: 3px; + background: #2f6af2; +} + +.workspace-empty-text { + margin: 4px 0 0; + color: #9ba7bc; + font-size: 11px; + text-align: center; +} + +.workspace-library-search { + width: 100%; + border: 1px solid #d5dce9; + border-radius: 8px; + height: 30px; + padding: 0 10px; + font-size: 12px; + color: #344866; + background: #ffffff; + outline: none; +} + +.workspace-library-search:focus { + border-color: #2f6af2; + box-shadow: 0 0 0 2px rgba(47, 106, 242, 0.14); +} + +.workspace-library-list { + max-height: 196px; + overflow-y: auto; + scroll-padding-top: 8px; +} + +.workspace-library-modal { + display: flex; + flex-direction: column; + gap: 10px; +} + +.workspace-library-modal .workspace-library-list { + border: 1px solid #dbe2ef; + border-radius: 8px; + max-height: 300px; + padding: 4px 0; +} + +.workspace-empty-text--modal { + margin: 0; +} + +.workspace-linked-library-group { + padding: 4px 0 6px; +} + +.workspace-linked-library-group + .workspace-linked-library-group { + border-top: 1px solid #eef2f8; +} + +.workspace-linked-library-group__header { + padding: 2px var(--workspace-left-section-padding-x) 6px; +} + +.workspace-linked-library-group__title { + font-size: 12px; + font-weight: 700; + color: #405374; +} + +.workspace-linked-library-group__meta { + margin-top: 2px; + font-size: 10px; + color: #8b97ac; +} + +.workspace-linked-library-category + .workspace-linked-library-category { + margin-top: 4px; +} + +.workspace-linked-library-category__toggle { + width: 100%; + border: none; + background: transparent; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 var(--workspace-left-section-padding-x) 3px; + cursor: pointer; + text-align: left; +} + +.workspace-linked-library-category__toggle:hover { + color: #4b6287; +} + +.workspace-linked-library-category__toggle .material-symbols-outlined { + font-size: 16px; + color: #7f8ea6; + transition: transform 0.2s ease; +} + +.workspace-linked-library-category__title { + flex: 1; + font-size: 11px; + font-weight: 600; + color: #5b6e8f; +} + +.workspace-linked-library-category__count { + min-width: 18px; + height: 18px; + padding: 0 6px; + border-radius: 999px; + background: #eef3fb; + color: #6d7f9c; + font-size: 10px; + line-height: 18px; + text-align: center; +} + +.workspace-library-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: var(--workspace-left-library-row-padding-y) var(--workspace-left-library-row-padding-x); + min-height: 52px; +} + +.workspace-library-item + .workspace-library-item { + border-top: 1px solid #eef2f8; +} + +.workspace-library-item:hover { + background: #f5f8ff; +} + +.workspace-library-item__content { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.workspace-library-item__title { + font-size: 13px; + line-height: 1.4; + color: #415474; + font-weight: 600; + white-space: normal; + word-break: break-word; +} + +.workspace-library-item__meta { + color: #8694ac; + font-size: 11px; + line-height: 1.3; +} + +.workspace-library-item__add { + border: 1px solid #c9d3e6; + background: #ffffff; + color: #3f5f9f; + border-radius: 7px; + height: var(--workspace-left-library-action-height); + min-width: var(--workspace-left-library-action-min-width); + font-size: 11px; + font-weight: 600; + cursor: pointer; + align-self: center; + flex-shrink: 0; +} + +.workspace-library-item__add:hover:enabled { + background: #edf3ff; +} + +.workspace-library-item__add:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.workspace-share-modal { + display: flex; + flex-direction: column; + gap: 12px; +} + +.workspace-share-modal__target { + margin: 0; + color: #415373; + font-size: 13px; + line-height: 1.5; + word-break: break-all; +} + +.workspace-share-modal__field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: #667794; +} + +.workspace-share-modal__select { + width: 100%; + min-height: 34px; + border: 1px solid #d5ddea; + border-radius: 8px; + background: #ffffff; + color: #415373; + font-size: 12px; + padding: 0 10px; + outline: none; +} + +.workspace-share-modal__select:focus { + border-color: #7ba0e8; +} + +.workspace-share-modal__actions { + margin-top: 4px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.workspace-share-modal__btn--primary { + border-color: #4f7ddf; + background: #3d6ddd; + color: #ffffff; +} + +.workspace-share-modal__btn--primary:hover:enabled { + background: #3565d4; +} + +.workspace-delete-modal { + display: flex; + flex-direction: column; + gap: 8px; +} + +.workspace-delete-modal p { + margin: 0; + color: #405272; + font-size: 13px; + line-height: 1.5; +} + +.workspace-delete-modal__hint { + color: #7b89a0 !important; + font-size: 12px !important; +} + +.workspace-delete-modal__actions { + margin-top: 6px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.workspace-delete-modal__btn { + border: 1px solid #d4dbe8; + border-radius: 8px; + min-width: 86px; + height: 34px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.workspace-delete-modal__btn--ghost { + background: #ffffff; + color: #405272; +} + +.workspace-delete-modal__btn--ghost:hover:enabled { + background: #f4f7fc; +} + +.workspace-delete-modal__btn--danger { + border-color: #dd5a5a; + background: #e55252; + color: #ffffff; +} + +.workspace-delete-modal__btn--danger:hover:enabled { + background: #d84b4b; +} + +.workspace-delete-modal__btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.workspace-resource-detail { + display: flex; + flex-direction: column; + gap: 12px; +} + +.workspace-resource-detail__title { + margin: 0; + color: #344866; + font-size: 14px; + font-weight: 600; + line-height: 1.45; + word-break: break-all; +} + +.workspace-resource-detail__value { + color: #415373; + word-break: break-all; +} + +.workspace-resource-detail__actions { + display: flex; + justify-content: flex-end; +} + +.workspace-card { + margin: 0 12px 12px; + border: 1px solid #d5dbe8; + border-radius: 10px; + background: #ffffff; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.workspace-card h3 { + margin: 0; + color: #3b4a66; + font-size: 13px; + font-weight: 700; +} + +.workspace-suggestion-list { + margin: 0; + padding: 0 0 0 16px; + display: flex; + flex-direction: column; + gap: 8px; + color: #4b5a75; + font-size: 12px; + line-height: 1.5; +} + +.workspace-textarea { + border: 1px solid #d8deea; + border-radius: 8px; + padding: 10px; + min-height: 100px; + resize: vertical; + font-size: 12px; + color: #344866; + outline: none; +} + +.workspace-textarea:focus { + border-color: #2f6af2; + box-shadow: 0 0 0 2px rgba(47, 106, 242, 0.15); +} + +.workspace-config-summary { + font-size: 11px; + color: #60708e; + padding: 8px; + border: 1px solid #dde3ee; + border-radius: 8px; + background: #f5f7fb; +} + +.workspace-action-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.workspace-btn { + height: 34px; + border-radius: 8px; + border: none; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.workspace-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.workspace-btn--ghost { + border: 1px solid #ced6e4; + background: #ffffff; + color: #3f506f; +} + +.workspace-btn--ghost:hover:enabled { + background: #f5f8ff; +} + +.workspace-btn--primary { + background: #2f6af2; + color: #ffffff; +} + +.workspace-btn--primary:hover:enabled { + background: #2456cb; +} + +.workspace-analysis-status { + border: 1px solid #dde3ee; + border-radius: 8px; + background: #f7f9fc; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.workspace-analysis-status__head { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + color: #5e6f8d; +} + +.workspace-pill { + padding: 2px 6px; + border-radius: 999px; + background: #e5eaf3; + color: #5e6f8d; + font-size: 10px; + font-weight: 700; +} + +.workspace-pill--done { + background: #d9f4e6; + color: #1f8f5f; +} + +.workspace-analysis-status p { + margin: 0; + color: #6a7a96; + font-size: 11px; +} + +.workspace-inline-action { + border: none; + background: transparent; + color: #2f6af2; + font-size: 11px; + font-weight: 600; + text-align: left; + padding: 0; + cursor: pointer; +} + +.workspace-inline-action--dark { + color: #42516f; +} + +.workspace-log-text { + margin: 0; + border: 1px solid #dbe2ef; + border-radius: 6px; + background: #ffffff; + padding: 8px; + font-size: 11px; + line-height: 1.6; + white-space: pre-wrap; + color: #445273; +} + +.workspace-admin-detail { + border: 1px solid #dbe2ef; + border-radius: 6px; + background: #ffffff; + padding: 8px; + font-size: 11px; + color: #425172; + display: flex; + flex-direction: column; + gap: 8px; +} + +.workspace-admin-detail__label { + color: #7a88a1; + font-size: 10px; + margin-bottom: 2px; +} + +.workspace-admin-detail pre { + margin: 0; + white-space: pre-wrap; +} + +.workspace-contest-list { + max-height: 230px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.workspace-contest-item { + width: 100%; + border: 1px solid #d8dfec; + border-radius: 8px; + background: #f8faff; + padding: 8px; + text-align: left; + cursor: pointer; +} + +.workspace-contest-item--active { + border-color: #7ca3f8; + background: #ebf2ff; +} + +.workspace-contest-item__name { + font-size: 12px; + font-weight: 600; + color: #3a4c6d; +} + +.workspace-contest-item__meta { + margin-top: 4px; + font-size: 10px; + color: #7483a0; +} + +.workspace-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.workspace-input { + width: 100%; + border: 1px solid #d5dce9; + border-radius: 8px; + height: 34px; + padding: 0 10px; + font-size: 12px; + color: #344866; + background: #ffffff; + outline: none; +} + +.workspace-input:focus { + border-color: #2f6af2; + box-shadow: 0 0 0 2px rgba(47, 106, 242, 0.14); +} + +.workspace-input--small { + width: 84px; +} + +.workspace-topk-row { + display: flex; + gap: 8px; + align-items: center; +} + +.workspace-topk-row label { + color: #627492; + font-size: 11px; +} + +.workspace-preset-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.workspace-preset-item { + width: 100%; + border: 1px solid #d8dfea; + border-radius: 8px; + background: #f7f9fd; + color: #475b7e; + font-size: 11px; + text-align: left; + padding: 7px 8px; + cursor: pointer; +} + +.workspace-preset-item:hover { + background: #edf3ff; +} + +.workspace-issue-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.workspace-issue-panel__hint { + margin: 8px 0 0; + font-size: 11px; + line-height: 1.5; + color: #667790; +} + +.workspace-issue-report-card { + margin-top: 8px; + border: 1px solid #fde7b1; + border-radius: 8px; + background: #fffbeb; + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.workspace-issue-report-card__title { + font-size: 12px; + font-weight: 700; + color: #7a4f0f; +} + +.workspace-issue-report-card p { + margin: 0; + font-size: 11px; + line-height: 1.5; + color: #8a631d; +} + +.workspace-issue-report-card__meta { + font-size: 10px; + color: #a17a37; +} + +.workspace-issue-list { + max-height: 320px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.workspace-issue-item { + border: 1px solid #dce3f0; + border-radius: 8px; + background: #ffffff; + padding: 10px; +} + +.workspace-issue-item__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.workspace-issue-item__title { + font-size: 12px; + font-weight: 600; + color: #344a6c; + line-height: 1.5; +} + +.workspace-issue-item__line { + margin: 6px 0 0; + font-size: 11px; + line-height: 1.5; + color: #5b6f90; +} + +.workspace-issue-item__line--suggestion { + color: #156f4f; +} + +.workspace-issue-item__meta { + margin: 6px 0 0; + font-size: 10px; + color: #8392a8; +} + +.workspace-issue-tag { + border-radius: 999px; + border: 1px solid transparent; + font-size: 10px; + line-height: 1; + padding: 3px 7px; + white-space: nowrap; +} + +.workspace-issue-tag--critical { + border-color: #fecaca; + color: #b91c1c; + background: #fee2e2; +} + +.workspace-issue-tag--high { + border-color: #fed7aa; + color: #b45309; + background: #ffedd5; +} + +.workspace-issue-tag--medium { + border-color: #fde68a; + color: #92400e; + background: #fef3c7; +} + +.workspace-issue-tag--low { + border-color: #bbf7d0; + color: #166534; + background: #dcfce7; +} + +.no-scrollbar::-webkit-scrollbar { + display: none; +} + +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} diff --git a/app/components/IconLogo.vue b/app/components/IconLogo.vue new file mode 100644 index 0000000..b61f40e --- /dev/null +++ b/app/components/IconLogo.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/app/components/InputEntry.vue b/app/components/InputEntry.vue index 03d7966..6e8df7a 100644 --- a/app/components/InputEntry.vue +++ b/app/components/InputEntry.vue @@ -9,26 +9,23 @@ function go() {