feat(ui): refresh dashboard and paper boat branding
No files matched your search
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=paper-boat">
|
||||
<title>ReplyPier 网站客服测试</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Paper Boat / 纸舟
|
||||
|
||||
Selected direction: round 3, option 2 / Paper Boat. Confirmed by user.
|
||||
|
||||
## Visual specification
|
||||
|
||||
- Preserve the existing dashboard and widget layout, typography, product naming and third-party logos.
|
||||
- Primary symbol: folded boat, three solid facets, no gradients, thin details, outline or lettering. Celadon `#2E6A5C`, sage `#8BB49C`, fold `#568B73`.
|
||||
- Small-size mark uses the same silhouette with stronger separation between hull facets; native SVG at 16–32px. Install icons have warm porcelain `#F4F1EB` background and sufficient maskable safe area.
|
||||
- Human presets: twelve editorial cut-paper portraits, consistent close crop, muted circular background, diverse adults. Replace the bundled preset images; preserve uploaded personal photos.
|
||||
- AI presets: ten vector paper-craft emblems (boat, letter, pinwheel, book, plane, kite, folded heart, house, leaf, boat in ink). Replace generated orb rendering while retaining stored variant indexes. Existing terminal option remains available with a muted celadon accent.
|
||||
- Shared brand SVG component for dashboard/chat footer marks; the standalone loader uses the same boat geometry.
|
||||
|
||||
## Element inventory / asset loop
|
||||
|
||||
| Element | Decision | Format / location | Consumers |
|
||||
| --- | --- | --- | --- |
|
||||
| Selected board | reuse, reference only | `reference-paper-boat.png` | design reference |
|
||||
| Primary boat mark | export as native vector | existing logo SVG paths | logo, favicon, footer |
|
||||
| App/install icons | export from boat vector | existing public PNG/ICO paths | PWA, iOS, notification badge |
|
||||
| Twelve human headshots | generate sheet, extract individual cells | existing `src/assets/avatars/avatar-*.png` | preset picker and uploaded preset selections |
|
||||
| Ten AI paper emblems | export native vector | `src/assets/avatars/paper-*.svg` | legacy orb variants, preview, widget |
|
||||
| Existing UI and uploaded photos | reuse | unchanged | all existing callers |
|
||||
|
||||
State: COMPLETE. Direction confirmation, asset creation, implementation and visual QA passed. Asset inventory and consumer paths are in `asset-manifest.json`. The local dashboard, widget and standalone loader have been rebuilt and the existing browser's PWA update has been activated.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `npm run type-check`: passed.
|
||||
- `./deploy/local/local.sh build`: passed for dashboard, widget and loader. Production loader also regenerated with `scripts/build-webclient-prod.ts`.
|
||||
- Targeted Vitest suites: 76 tests passed across the avatar, upload URL, loader lifecycle/identity, startup refusal, login, sidebar and dashboard paths. On this machine, use `NODE_OPTIONS=--no-experimental-webstorage` so Node 26's Web Storage global does not mask jsdom storage.
|
||||
- Browser inspection: favicon, sidebar mark, all preset thumbnails, preview avatar/header/message bubbles and actual embedded widget footer/header/message avatars. Selected a human preset, verified its upload and preview, then restored the demo's selected paper boat and inspected the real widget.
|
||||
- Updated the dashboard badge test's stale `.notification-badge` selector to the existing `.topbar .badge-float` markup; notification behavior was unchanged.
|
||||
- No browser relaunch or new debugging socket, service restart or database migration was needed.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Keep `avatar_style: orb`, `orb_variant` and the existing utility exports as storage/API compatibility names. Generated orb data URIs are replaced at render time from metadata, without modifying customer uploads or database history. No database migration is needed.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Production portrait generation
|
||||
|
||||
Tool: built-in `image_gen`.
|
||||
|
||||
Style reference: `reference-paper-boat.png`.
|
||||
|
||||
Output: `assets/portraits-sheet.png`, extracted as twelve 360×360 PNG files into `frontend/src/assets/avatars/`.
|
||||
|
||||
## Prompt
|
||||
|
||||
Generate a PRODUCTION AVATAR SPRITE SHEET ONLY. Use the attached Paper Boat board solely as an illustration-style reference. Create exactly TWELVE distinct professional adult head-and-shoulders portraits in a precise 4 columns by 3 rows grid, landscape 4:3 canvas (ideally 2048x1536). Twelve equal SQUARE CELLS with NO GAPS, NO labels, NO text, NO logos, NO outlines or grid rules. Each cell must contain exactly ONE complete head and shoulders, head centered at 50% horizontal and about 45% vertical. Each portrait has a single solid pale sage background #DCE5D6 filling its entire square cell to all four edges, IDENTICAL background in every cell. Do NOT draw a circular frame or white circle: the application clips these squares into circles itself. Keep all hair, head and facial features within the central 76% of the cell so circular cropping never cuts hair; shoulders can extend to lower edges. Consistent generous face size, head occupies about 60% cell height. Cohesive high-end cut-paper editorial illustration, very simple crisp flat shapes, subtle deliberate asymmetry, warm and human. Similar warmth to the six small human avatars on the reference board, but ZERO 3D, ZERO realistic skin shading, ZERO shiny hair, ZERO gradients, ZERO plastic, ZERO brush texture, ZERO airbrushing, ZERO Disney style. Economical facial features, small calm eyes, subtle friendly smiles, elegant silhouettes, muted celadon, ivory, ochre and terracotta clothing. NO repeated faces.
|
||||
Positions in strict row-major order:
|
||||
Row1 col1 East Asian woman, shoulder-length black bob, cream shirt under green jacket;
|
||||
Row1 col2 Black man, short tightly curled black hair, clean-shaven, terracotta shirt;
|
||||
Row1 col3 white woman, shoulder-length auburn hair, round dark glasses, cream top;
|
||||
Row1 col4 South Asian man, wavy dark hair and neat beard, dark glasses, green shirt;
|
||||
Row2 col1 woman in muted celadon hijab, warm medium tan skin, cream blouse;
|
||||
Row2 col2 Latino man, short dark hair, light mustache, ochre shirt;
|
||||
Row2 col3 East Asian woman, long dark hair with side part, green blouse;
|
||||
Row2 col4 Black woman, natural round curly hair, small gold earrings, terracotta blouse;
|
||||
Row3 col1 white man, swept brown hair, short beard and rectangular glasses, cream shirt;
|
||||
Row3 col2 South Asian woman, long wavy dark hair, ochre blouse;
|
||||
Row3 col3 Latina woman, short dark curly hair, terracotta shirt;
|
||||
Row3 col4 East Asian man, short neat black hair, clean-shaven, green shirt.
|
||||
All illustrated adults, no children. Keep style, crop, light and color family consistent. This is a raw asset sheet intended for exact equal-grid extraction into twelve standalone PNG files, NOT a concept board or a website UI. Do not add anything outside the portraits.
|
||||
@@ -0,0 +1,36 @@
|
||||
# ReplyPier 图标与头像更新
|
||||
|
||||
## Brief
|
||||
|
||||
将网页标签图标、聊天底部品牌标识及客服头像重新设计并替换。保持现有页面布局与功能;其他图片先征求用户意见。现有工作区有未提交的界面更新,不覆盖。
|
||||
|
||||
## Pipeline
|
||||
|
||||
- Brief / inventory: complete
|
||||
- Style exploration: complete; user selected round 3 option 2, Paper Boat.
|
||||
- Design spec / tokens: `DESIGN.md`; complete.
|
||||
- Assets / consumers / QA: `asset-manifest.json`.
|
||||
- Production assets: twelve generated portraits, ten native paper-craft SVGs, shared boat mark and favicon/install exports.
|
||||
- Implementation: shared `BrandMark.vue`, compatible avatar utilities, dashboard preview, live widget and standalone loader.
|
||||
- Verification: type check and local dashboard/widget/loader builds passed. Targeted tests passed (76 total across the affected suites). Browser QA includes the preset picker and switching from paper artwork to an uploaded portrait and back.
|
||||
|
||||
## Current sources
|
||||
|
||||
- Browser icon: `frontend/public/favicon.svg`; duplicate `frontend/public/assets/favicon.svg`; ICO fallbacks at both locations.
|
||||
- Brand mark: `frontend/src/assets/logo.svg`, `frontend/public/images/logo.svg`, `frontend/public/assets/images/logo.svg`, `logo_dark.svg`.
|
||||
- Footer marks: shared `frontend/src/components/common/BrandMark.vue`; `frontend/src/webclient/replypier.js` imports the same source SVG for its error footer.
|
||||
- Preset portraits: 12 PNGs in `frontend/src/assets/avatars/avatar-*.png`; selected through `AgentDetail.vue`.
|
||||
- Paper-craft avatars: ten SVGs under `frontend/src/assets/avatars/paper-*.svg`; loaded by `frontend/src/utils/orb.ts` under the original stored variant indexes. Old generated data URIs are refreshed from avatar metadata at render time. Uploaded photos remain unchanged.
|
||||
- Additional candidates: PWA/install icons, default user/robot and role avatars, login/signup illustration. Third-party integration logos should retain official identities.
|
||||
|
||||
## Design constraints
|
||||
|
||||
Use existing ink/porcelain foundation: paper #F4F1EB, ink #16171A, vermilion #B23A22, celadon #2E6A5C, brass #8A6326. Keep 16px brand marks simple and readable. No change to customer-uploaded images or unrelated UI. Visible product naming currently mixes ReplyPier and ReplyPier; do not infer a full rename from the asset request.
|
||||
|
||||
## Concepts
|
||||
|
||||
A — Harbour / 港湾:restrained celadon speech-bubble mark, flat editorial portraits; low complexity; safe for business use, less distinctive.
|
||||
B — Seal / 回音印:vermilion reply seal, warm illustrated portraits and restrained AI medallions; medium complexity; best match to existing tokens, detail must survive small sizes.
|
||||
C — Beacon / 信标:ink/celadon geometric beacon, playful abstract AI companions and graphic portraits; medium complexity; memorable but more characterful than conventional support branding.
|
||||
|
||||
Each board shows brand mark, compact lockup, 16/32px icon simulation, chat footer/header application and portrait/AI avatar samples. Typography follows the existing Hanken Grotesk / Chinese sans foundation; only the concept headings may be editorial.
|
||||
@@ -0,0 +1,299 @@
|
||||
{
|
||||
"ui_assets_complete": true,
|
||||
"phase": "complete",
|
||||
"selected_direction": "Paper Boat / round 3 option 2",
|
||||
"references": [
|
||||
{
|
||||
"file": "design/brand-refresh/reference-paper-boat.png",
|
||||
"role": "approved visual reference",
|
||||
"used_as_asset": false
|
||||
}
|
||||
],
|
||||
"element_inventory": [
|
||||
{
|
||||
"id": "boat-mark",
|
||||
"decision": "export",
|
||||
"target_format": "svg"
|
||||
},
|
||||
{
|
||||
"id": "install-icons",
|
||||
"decision": "export",
|
||||
"target_format": "png/ico"
|
||||
},
|
||||
{
|
||||
"id": "human-avatars",
|
||||
"decision": "generate",
|
||||
"target_format": "png"
|
||||
},
|
||||
{
|
||||
"id": "paper-avatars",
|
||||
"decision": "export",
|
||||
"target_format": "svg"
|
||||
}
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"element_id": "boat-mark",
|
||||
"file": "frontend/src/assets/logo.svg",
|
||||
"status": "ready",
|
||||
"usage": "shared footer brand component and logo mirrors"
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"file": "frontend/src/assets/avatars/avatar-01-asian-woman-1.png",
|
||||
"status": "ready",
|
||||
"source": "generated 4x3 sheet; equal-cell extraction"
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"file": "frontend/src/assets/avatars/avatar-02-black-man-1.png",
|
||||
"status": "ready",
|
||||
"source": "generated 4x3 sheet; equal-cell extraction"
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"file": "frontend/src/assets/avatars/avatar-03-white-woman-glasses.png",
|
||||
"status": "ready",
|
||||
"source": "generated 4x3 sheet; equal-cell extraction"
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"file": "frontend/src/assets/avatars/avatar-04-south-asian-man-glasses.png",
|
||||
"status": "ready",
|
||||
"source": "generated 4x3 sheet; equal-cell extraction"
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"file": "frontend/src/assets/avatars/avatar-05-hijab-woman.png",
|
||||
"status": "ready",
|
||||
"source": "generated 4x3 sheet; equal-cell extraction"
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"file": "frontend/src/assets/avatars/avatar-06-latino-man.png",
|
||||
"status": "ready",
|
||||
"source": "generated 4x3 sheet; equal-cell extraction"
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"file": "frontend/src/assets/avatars/avatar-07-asian-woman-2.png",
|
||||
"status": "ready",
|
||||
"source": "generated 4x3 sheet; equal-cell extraction"
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"file": "frontend/src/assets/avatars/avatar-08-black-woman-curly.png",
|
||||
"status": "ready",
|
||||
"source": "generated 4x3 sheet; equal-cell extraction"
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"file": "frontend/src/assets/avatars/avatar-09-white-man-glasses.png",
|
||||
"status": "ready",
|
||||
"source": "generated 4x3 sheet; equal-cell extraction"
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"file": "frontend/src/assets/avatars/avatar-10-south-asian-woman.png",
|
||||
"status": "ready",
|
||||
"source": "generated 4x3 sheet; equal-cell extraction"
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"file": "frontend/src/assets/avatars/avatar-11-latina-woman.png",
|
||||
"status": "ready",
|
||||
"source": "generated 4x3 sheet; equal-cell extraction"
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"file": "frontend/src/assets/avatars/avatar-12-east-asian-man.png",
|
||||
"status": "ready",
|
||||
"source": "generated 4x3 sheet; equal-cell extraction"
|
||||
},
|
||||
{
|
||||
"element_id": "paper-avatars",
|
||||
"file": "frontend/src/assets/avatars/paper-boat.svg",
|
||||
"alt": "纸舟",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "paper-avatars",
|
||||
"file": "frontend/src/assets/avatars/paper-letter.svg",
|
||||
"alt": "信笺",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "paper-avatars",
|
||||
"file": "frontend/src/assets/avatars/paper-pinwheel.svg",
|
||||
"alt": "风车",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "paper-avatars",
|
||||
"file": "frontend/src/assets/avatars/paper-book.svg",
|
||||
"alt": "书页",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "paper-avatars",
|
||||
"file": "frontend/src/assets/avatars/paper-plane.svg",
|
||||
"alt": "纸翼",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "paper-avatars",
|
||||
"file": "frontend/src/assets/avatars/paper-kite.svg",
|
||||
"alt": "风筝",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "paper-avatars",
|
||||
"file": "frontend/src/assets/avatars/paper-heart.svg",
|
||||
"alt": "折心",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "paper-avatars",
|
||||
"file": "frontend/src/assets/avatars/paper-house.svg",
|
||||
"alt": "纸屋",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "paper-avatars",
|
||||
"file": "frontend/src/assets/avatars/paper-leaf.svg",
|
||||
"alt": "新叶",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "paper-avatars",
|
||||
"file": "frontend/src/assets/avatars/paper-ink-boat.svg",
|
||||
"alt": "墨舟",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "install-icons",
|
||||
"file": "frontend/public/favicon.svg",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "install-icons",
|
||||
"file": "frontend/public/assets/favicon.svg",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "install-icons",
|
||||
"file": "frontend/public/favicon.ico",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "install-icons",
|
||||
"file": "frontend/public/assets/favicon.ico",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "install-icons",
|
||||
"file": "frontend/public/apple-touch-icon-180x180.png",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "install-icons",
|
||||
"file": "frontend/public/pwa-192x192.png",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "install-icons",
|
||||
"file": "frontend/public/pwa-512x512.png",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "install-icons",
|
||||
"file": "frontend/public/pwa-maskable-512x512.png",
|
||||
"status": "ready"
|
||||
},
|
||||
{
|
||||
"element_id": "install-icons",
|
||||
"file": "frontend/public/notification-badge-96.png",
|
||||
"status": "ready"
|
||||
}
|
||||
],
|
||||
"tool_attempts": [
|
||||
{
|
||||
"tool": "built-in image_gen",
|
||||
"action": "generate portrait sheet from approved reference",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"tool": "sips",
|
||||
"action": "extract twelve equal cells",
|
||||
"status": "success"
|
||||
},
|
||||
{
|
||||
"tool": "native SVG export + rsvg-convert",
|
||||
"action": "export paper symbols and raster install icons",
|
||||
"status": "success"
|
||||
}
|
||||
],
|
||||
"qa": [
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"status": "passed",
|
||||
"checks": [
|
||||
"12 complete distinct adults",
|
||||
"consistent style",
|
||||
"no baked-in labels"
|
||||
]
|
||||
},
|
||||
{
|
||||
"element_id": "boat-mark",
|
||||
"status": "passed",
|
||||
"checks": [
|
||||
"16px actual browser render"
|
||||
]
|
||||
},
|
||||
{
|
||||
"element_id": "human-avatars",
|
||||
"status": "passed",
|
||||
"checks": [
|
||||
"all 12 cells individually extracted",
|
||||
"first and last crops visually inspected",
|
||||
"sips zero-offset centering corrected with inset extraction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"element_id": "paper-avatars",
|
||||
"status": "passed",
|
||||
"checks": [
|
||||
"all ten SVGs parsed by tests",
|
||||
"old stored orb metadata re-renders new vectors",
|
||||
"CSS and image slots use identical SVGs",
|
||||
"uploaded photos pass through unchanged"
|
||||
]
|
||||
}
|
||||
],
|
||||
"omitted": [
|
||||
{
|
||||
"item": "old login illustration, third-party logos and customer uploads",
|
||||
"reason": "outside selected asset scope"
|
||||
}
|
||||
],
|
||||
"portrait_prompt": "Twelve equal square cells in a 4x3 sheet; flat editorial cut-paper adult portraits; identical pale sage background, no labels, no glossy or photorealistic rendering. Approved Paper Boat board used as style reference.",
|
||||
"tokens": {
|
||||
"paper": "#F4F1EB",
|
||||
"ink": "#16171A",
|
||||
"celadon": "#2E6A5C",
|
||||
"sage": "#8BB49C",
|
||||
"fold": "#568B73"
|
||||
},
|
||||
"components": [
|
||||
"frontend/src/components/common/BrandMark.vue",
|
||||
"frontend/src/utils/orb.ts"
|
||||
],
|
||||
"validation": {
|
||||
"type_check": "passed",
|
||||
"dashboard_widget_loader_build": "passed",
|
||||
"targeted_tests": "76 total passing: 65 unaffected tests from first corrected run + 11 final BrandMark/dashboard checks",
|
||||
"runtime": "dashboard and widget assets return HTTP 200; browser PWA update activated; favicon/sidebar/footer/picker inspected; human preset upload verified and paper boat restored; live embedded widget header/message/footer inspected"
|
||||
},
|
||||
"all_code_native_approved": false,
|
||||
"semantic_media_policy": "Portraits generated and extracted; brand and paper emblems exported as native SVG assets."
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -11,9 +11,10 @@ export class Dashboard {
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await step('Log out', async () => {
|
||||
// The profile menu trigger is an avatar with no accessible name.
|
||||
await this.page.locator('.profile-trigger').click()
|
||||
await this.page.getByRole('button', { name: 'Logout' }).click()
|
||||
// The account block lives at the foot of the sidebar; the menu it opens
|
||||
// holds the sign-out action.
|
||||
await this.page.locator('.sidebar .me').click()
|
||||
await this.page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(this.page).toHaveURL(/\/login/)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=paper-boat">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<meta name="theme-color" content="#0B0C10">
|
||||
<meta name="theme-color" content="#F4F1EB">
|
||||
<script>
|
||||
/* Applies the saved theme before first paint.
|
||||
useTheme.ts sets data-theme and theme-color too, but only once the bundle
|
||||
@@ -19,13 +19,13 @@
|
||||
and src/config/themeColors.ts — a static document cannot import them. */
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem('rp-theme') || 'dark';
|
||||
var stored = localStorage.getItem('rp-theme') || 'light';
|
||||
var resolved = stored === 'system'
|
||||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: (stored === 'light' ? 'light' : 'dark');
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
var meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', resolved === 'dark' ? '#0B0C10' : '#EEF0F4');
|
||||
if (meta) meta.setAttribute('content', resolved === 'dark' ? '#121211' : '#F4F1EB');
|
||||
} catch (e) {
|
||||
/* Private mode can throw on localStorage; the dark default already applies. */
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..500&family=Hanken+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&family=Noto+Serif+SC:wght@500;600;700&display=swap">
|
||||
<title>ReplyPier · 智能客服工作台</title>
|
||||
<meta name="shopify-api-key" content="<%= VITE_SHOPIFY_API_KEY %>" />
|
||||
<!-- Shopify App Bridge, and the Polaris web components the Shopify views use.
|
||||
|
||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,6 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="1.5" y="1.5" width="29" height="29" rx="8.5" fill="#C9F24E"/>
|
||||
<circle cx="10.5" cy="16" r="2.3" fill="#0B0C10"/>
|
||||
<circle cx="16" cy="16" r="2.3" fill="#0B0C10"/>
|
||||
<circle cx="21.5" cy="16" r="2.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#F4F1EB"/><g transform="translate(0 5)"><path d="M29 20 39 6 42 21Z" fill="#568B73"/><path d="M4 16 32 26 23 43H15Z" fill="#2E6A5C"/><path d="M35 26 60 16 49 43H27Z" fill="#8BB49C"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 298 B After Width: | Height: | Size: 296 B |
@@ -1,18 +1 @@
|
||||
<svg width="32" height="32" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Chat bubble background -->
|
||||
<path
|
||||
d="M95 20H25C22.2386 20 20 22.2386 20 25V75C20 77.7614 22.2386 80 25 80H45L55 95L65 80H95C97.7614 80 100 77.7614 100 75V25C100 22.2386 97.7614 20 95 20Z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
|
||||
<!-- Letter C -->
|
||||
<path
|
||||
d="M75 40C75 40 65 35 60 35C50 35 45 42 45 50C45 58 50 65 60 65C65 65 75 60 75 60V52C75 52 68 55 63 55C57 55 53 53 53 50C53 47 57 45 63 45C68 45 75 48 75 48V40Z"
|
||||
fill="#f34611"
|
||||
/>
|
||||
|
||||
<!-- Dots -->
|
||||
<!-- <circle cx="45" cy="50" r="3" fill="#BFEEAA"/>
|
||||
<circle cx="60" cy="50" r="3" fill="#BFEEAA"/>
|
||||
<circle cx="75" cy="50" r="3" fill="#BFEEAA"/> -->
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 48" fill="none"><path d="M29 20 39 6 42 21Z" fill="#568B73"/><path d="M4 16 32 26 23 43H15Z" fill="#2E6A5C"/><path d="M35 26 60 16 49 43H27Z" fill="#8BB49C"/></svg>
|
||||
|
Before Width: | Height: | Size: 728 B After Width: | Height: | Size: 221 B |
@@ -1,20 +1 @@
|
||||
<svg width="1200" height="1200" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Chat bubble background with subtle outline -->
|
||||
<path
|
||||
d="M95 20H25C22.2386 20 20 22.2386 20 25V75C20 77.7614 22.2386 80 25 80H45L55 95L65 80H95C97.7614 80 100 77.7614 100 75V25C100 22.2386 97.7614 20 95 20Z"
|
||||
fill="#f34611"
|
||||
stroke="#EEEEEE"
|
||||
stroke-width="1"
|
||||
/>
|
||||
|
||||
<!-- Letter C -->
|
||||
<path
|
||||
d="M75 40C75 40 65 35 60 35C50 35 45 42 45 50C45 58 50 65 60 65C65 65 75 60 75 60V52C75 52 68 55 63 55C57 55 53 53 53 50C53 47 57 45 63 45C68 45 75 48 75 48V40Z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
|
||||
<!-- Dots -->
|
||||
<!-- <circle cx="45" cy="50" r="3" fill="#BFEEAA"/>
|
||||
<circle cx="60" cy="50" r="3" fill="#BFEEAA"/>
|
||||
<circle cx="75" cy="50" r="3" fill="#BFEEAA"/> -->
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 48" fill="none"><path d="M29 20 39 6 42 21Z" fill="#568B73"/><path d="M4 16 32 26 23 43H15Z" fill="#2E6A5C"/><path d="M35 26 60 16 49 43H27Z" fill="#8BB49C"/></svg>
|
||||
|
Before Width: | Height: | Size: 794 B After Width: | Height: | Size: 221 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,6 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="1.5" y="1.5" width="29" height="29" rx="8.5" fill="#C9F24E"/>
|
||||
<circle cx="10.5" cy="16" r="2.3" fill="#0B0C10"/>
|
||||
<circle cx="16" cy="16" r="2.3" fill="#0B0C10"/>
|
||||
<circle cx="21.5" cy="16" r="2.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#F4F1EB"/><g transform="translate(0 5)"><path d="M29 20 39 6 42 21Z" fill="#568B73"/><path d="M4 16 32 26 23 43H15Z" fill="#2E6A5C"/><path d="M35 26 60 16 49 43H27Z" fill="#8BB49C"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 298 B After Width: | Height: | Size: 296 B |
@@ -1,6 +1 @@
|
||||
<svg width="32" height="32" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="20" y="20" width="80" height="80" rx="22" fill="#C9F24E"/>
|
||||
<circle cx="42" cy="60" r="6" fill="#0B0C10"/>
|
||||
<circle cx="60" cy="60" r="6" fill="#0B0C10"/>
|
||||
<circle cx="78" cy="60" r="6" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 48" fill="none"><path d="M29 20 39 6 42 21Z" fill="#568B73"/><path d="M4 16 32 26 23 43H15Z" fill="#2E6A5C"/><path d="M35 26 60 16 49 43H27Z" fill="#8BB49C"/></svg>
|
||||
|
Before Width: | Height: | Size: 322 B After Width: | Height: | Size: 221 B |
|
Before Width: | Height: | Size: 516 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.7 KiB |
@@ -1,4 +1,5 @@
|
||||
(()=>{var wt=(at,ot,e)=>new Promise((M,d)=>{var j=x=>{try{B(e.next(x))}catch(N){d(N)}},V=x=>{try{B(e.throw(x))}catch(N){d(N)}},B=x=>x.done?M(x.value):Promise.resolve(x.value).then(j,V);B((e=e.apply(at,ot)).next())});window.replypierId;window.replypierBaseUrl;window.ReplyPier;window.replypierConfig;(function(){function at(t){return/^#[0-9A-F]{6}$/i.test(t)}function ot(){return typeof window!="undefined"&&window.replypierBaseUrl?window.replypierBaseUrl:typeof window!="undefined"&&window.APP_CONFIG&&window.APP_CONFIG.API_URL?window.APP_CONFIG.API_URL:"https://api.replypier.site/api/v1"}let e={baseUrl:ot(),containerId:"replypier-container",backdropId:"replypier-backdrop",buttonId:"replypier-button",chatBubbleColor:"#f34611",loadingContainerId:"replypier-loading",tokenKey:"rpid",tokenProvider:null,containerBottom:100,containerRight:20,containerWidth:400,containerHeight:560,launcherBottom:20,launcherRight:20,side:"right",displayMode:"floating",launcher:!0,sidebarWidth:420,searchBarWidth:320,searchPlaceholder:"Ask anything...",zIndex:999999,trigger:null,hotkey:!0,surfaceIsPalette:null,chatStyle:null,chatInitiationMessages:[],initiationMessageId:"replypier-initiation",initiationShownKey:"rpim_shown",unreadCount:0,hasOpened:!1},M={};function d(t,n){e[t]=n,M[t]=!0}let j=["floating","sidebar-left","sidebar-right","search-bar"],V=["ASK_ANYTHING","AURORA"],B=680,x=620,N=140,S={width:!1,height:!1},P=0;function q(){return e.surfaceIsPalette===!1?!1:(V.indexOf(e.chatStyle)!==-1||e.displayMode==="search-bar")&&e.displayMode!=="sidebar-left"&&e.displayMode!=="sidebar-right"}function Z(){let t=document.getElementById(e.containerId);if(!t)return;if(!q()||!P){t.style.height="";return}let n=Math.min(rt().height,Math.round(window.innerHeight*.76));t.style.height=Math.max(N,Math.min(n,P))+"px"}function rt(){return{width:S.width?e.containerWidth:B,height:S.height?e.containerHeight:x}}function _t(t){if(!t||typeof t!="object")return;let n=function(i,o){M[i]||(e[i]=o)};j.indexOf(t.mode)!==-1&&n("displayMode",t.mode),(t.side==="left"||t.side==="right")&&n("side",t.side),typeof t.launcher=="boolean"&&n("launcher",t.launcher),m(t.width)&&(n("containerWidth",Math.max(280,t.width)),M.containerWidth||(S.width=!0)),m(t.height)&&(n("containerHeight",Math.max(400,t.height)),M.containerHeight||(S.height=!0)),m(t.sidebar_width)&&n("sidebarWidth",Math.max(280,t.sidebar_width)),typeof t.search_placeholder=="string"&&t.search_placeholder&&n("searchPlaceholder",t.search_placeholder.slice(0,80)),m(t.offset_bottom)&&n("launcherBottom",Math.max(0,t.offset_bottom)),m(t.offset_side)&&n("launcherRight",Math.max(0,t.offset_side)),m(t.z_index)&&n("zIndex",Math.max(1,Math.floor(t.z_index)))}function It(){let t=q(),n=t?rt():{width:e.containerWidth,height:e.containerHeight};return{type:"WIDGET_DISPLAY",mode:t?"ask-ai":e.displayMode,width:n.width,height:n.height,hotkey:e.hotkey!==!1}}let c=null,R=[],X=!1,g=null,T=null,y=!1;function J(t){c?t():y||R.push(t)}let Q={},st=!1;function _(t,n){let i=Q[t];!i||!i.length||i.slice().forEach(function(o){try{o(n)}catch(h){}})}function Lt(t){let n=/^#?([0-9a-f]{6})$/i.exec((t||"").trim());if(!n)return"#0B0C10";let i=parseInt(n[1],16);return(.299*(i>>16&255)+.587*(i>>8&255)+.114*(i&255))/255>.6?"#0B0C10":"#FFFFFF"}let ct=!1,E=null,lt=[];function F(){if(ct)return;ct=!0,E&&(clearTimeout(E),E=null);let t=document.getElementById(e.buttonId);t&&t.classList.remove("replypier-pending"),lt.splice(0).forEach(n=>{try{n()}catch(i){}})}function O(){let t=document.getElementById(e.buttonId);if(!t)return;let n=t.querySelector(".rp-badge");if(!n)return;let i=e.hasOpened?e.unreadCount:(e.chatInitiationMessages||[]).length;n.textContent=i>99?"99+":String(i),n.style.display=i>0&&!t.classList.contains("active")?"flex":"none"}function W(){let t=e.displayMode,n=t==="sidebar-left"||t==="sidebar-right",i=t==="sidebar-left"?"left":t==="sidebar-right"?"right":e.side,o=t==="search-bar"?48:64,h=V.indexOf(e.chatStyle)===-1,r=q(),l=rt(),w=document.createElement("style");w.id="replypier-styles",w.textContent=`
|
||||
(()=>{var wt=(nt,it,e)=>new Promise((M,d)=>{var Y=x=>{try{z(e.next(x))}catch(B){d(B)}},G=x=>{try{z(e.throw(x))}catch(B){d(B)}},z=x=>x.done?M(x.value):Promise.resolve(x.value).then(Y,G);z((e=e.apply(nt,it)).next())});var _t=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 48" fill="none"><path d="M29 20 39 6 42 21Z" fill="#568B73"/><path d="M4 16 32 26 23 43H15Z" fill="#2E6A5C"/><path d="M35 26 60 16 49 43H27Z" fill="#8BB49C"/></svg>
|
||||
`;window.replypierId;window.replypierBaseUrl;window.ReplyPier;window.replypierConfig;(function(){function nt(t){return/^#[0-9A-F]{6}$/i.test(t)}function it(){return typeof window!="undefined"&&window.replypierBaseUrl?window.replypierBaseUrl:typeof window!="undefined"&&window.APP_CONFIG&&window.APP_CONFIG.API_URL?window.APP_CONFIG.API_URL:"https://api.replypier.site/api/v1"}let e={baseUrl:it(),containerId:"replypier-container",backdropId:"replypier-backdrop",buttonId:"replypier-button",chatBubbleColor:"#f34611",loadingContainerId:"replypier-loading",tokenKey:"rpid",tokenProvider:null,containerBottom:100,containerRight:20,containerWidth:400,containerHeight:560,launcherBottom:20,launcherRight:20,side:"right",displayMode:"floating",launcher:!0,sidebarWidth:420,searchBarWidth:320,searchPlaceholder:"\u6709\u95EE\u9898\u968F\u65F6\u95EE\u6211\u2026",zIndex:999999,trigger:null,hotkey:!0,surfaceIsPalette:null,chatStyle:null,chatInitiationMessages:[],initiationMessageId:"replypier-initiation",initiationShownKey:"rpim_shown",unreadCount:0,hasOpened:!1},M={};function d(t,n){e[t]=n,M[t]=!0}let Y=["floating","sidebar-left","sidebar-right","search-bar"],G=["ASK_ANYTHING","AURORA"],z=680,x=620,B=140,A={width:!1,height:!1},N=0;function j(){return e.surfaceIsPalette===!1?!1:(G.indexOf(e.chatStyle)!==-1||e.displayMode==="search-bar")&&e.displayMode!=="sidebar-left"&&e.displayMode!=="sidebar-right"}function q(){let t=document.getElementById(e.containerId);if(!t)return;if(!j()||!N){t.style.height="";return}let n=Math.min(at().height,Math.round(window.innerHeight*.76));t.style.height=Math.max(B,Math.min(n,N))+"px"}function at(){return{width:A.width?e.containerWidth:z,height:A.height?e.containerHeight:x}}function Lt(t){if(!t||typeof t!="object")return;let n=function(i,o){M[i]||(e[i]=o)};Y.indexOf(t.mode)!==-1&&n("displayMode",t.mode),(t.side==="left"||t.side==="right")&&n("side",t.side),typeof t.launcher=="boolean"&&n("launcher",t.launcher),m(t.width)&&(n("containerWidth",Math.max(280,t.width)),M.containerWidth||(A.width=!0)),m(t.height)&&(n("containerHeight",Math.max(400,t.height)),M.containerHeight||(A.height=!0)),m(t.sidebar_width)&&n("sidebarWidth",Math.max(280,t.sidebar_width)),typeof t.search_placeholder=="string"&&t.search_placeholder&&n("searchPlaceholder",t.search_placeholder.slice(0,80)),m(t.offset_bottom)&&n("launcherBottom",Math.max(0,t.offset_bottom)),m(t.offset_side)&&n("launcherRight",Math.max(0,t.offset_side)),m(t.z_index)&&n("zIndex",Math.max(1,Math.floor(t.z_index)))}function It(){let t=j(),n=t?at():{width:e.containerWidth,height:e.containerHeight};return{type:"WIDGET_DISPLAY",mode:t?"ask-ai":e.displayMode,width:n.width,height:n.height,hotkey:e.hotkey!==!1}}let c=null,R=[],V=!1,g=null,T=null,y=!1;function Z(t){c?t():y||R.push(t)}let X={},ot=!1;function _(t,n){let i=X[t];!i||!i.length||i.slice().forEach(function(o){try{o(n)}catch(h){}})}function zt(t){let n=/^#?([0-9a-f]{6})$/i.exec((t||"").trim());if(!n)return"#0B0C10";let i=parseInt(n[1],16);return(.299*(i>>16&255)+.587*(i>>8&255)+.114*(i&255))/255>.6?"#0B0C10":"#FFFFFF"}let rt=!1,E=null,st=[];function P(){if(rt)return;rt=!0,E&&(clearTimeout(E),E=null);let t=document.getElementById(e.buttonId);t&&t.classList.remove("replypier-pending"),st.splice(0).forEach(n=>{try{n()}catch(i){}})}function F(){let t=document.getElementById(e.buttonId);if(!t)return;let n=t.querySelector(".rp-badge");if(!n)return;let i=e.hasOpened?e.unreadCount:(e.chatInitiationMessages||[]).length;n.textContent=i>99?"99+":String(i),n.style.display=i>0&&!t.classList.contains("active")?"flex":"none"}function O(){let t=e.displayMode,n=t==="sidebar-left"||t==="sidebar-right",i=t==="sidebar-left"?"left":t==="sidebar-right"?"right":e.side,o=t==="search-bar"?48:64,h=G.indexOf(e.chatStyle)===-1,r=j(),l=at(),w=document.createElement("style");w.id="replypier-styles",w.textContent=`
|
||||
/* ===== Rounded-square launcher (design comp) ===== */
|
||||
#${e.buttonId} {
|
||||
position: fixed;
|
||||
@@ -8,7 +9,7 @@
|
||||
height: 64px;
|
||||
border-radius: 20px 20px 20px 6px;
|
||||
background: ${e.chatBubbleColor};
|
||||
color: ${Lt(e.chatBubbleColor)};
|
||||
color: ${zt(e.chatBubbleColor)};
|
||||
box-shadow: 0 16px 40px -8px ${e.chatBubbleColor}cc, inset 0 0 0 1px rgba(255,255,255,0.08);
|
||||
cursor: pointer;
|
||||
z-index: ${e.zIndex};
|
||||
@@ -864,11 +865,11 @@
|
||||
re-show (display: flex !important at equal specificity). */
|
||||
#${e.buttonId}, #${e.buttonId}.mobile-closed { display: none !important; }
|
||||
`}
|
||||
`;let f=document.getElementById("replypier-styles");f&&f.remove(),document.head.appendChild(w),vt()}function vt(){let t=document.getElementById(e.buttonId);if(!t)return;let n=t.querySelector(".rp-search-placeholder");n&&n.textContent!==e.searchPlaceholder&&(n.textContent=e.searchPlaceholder)}function C(t){if(typeof t!="string")return null;let n=t.trim();return!n||n==="undefined"||n==="null"?null:n}function dt(){return C(window.replypierToken)||C(localStorage.getItem(e.tokenKey))}function ht(t){let n=C(t);if(!n)return null;let i=n.split(".")[1];if(!i)return null;try{let o=JSON.parse(atob(i.replace(/-/g,"+").replace(/_/g,"/")));return o.sub||o.customer_id||null}catch(o){return null}}function tt(t){t&&localStorage.setItem(e.tokenKey,t)}function H(){localStorage.removeItem(e.tokenKey)}function U(){return window.innerWidth<=768}function zt(t){return t?t.replace(/\uFFFD/g,"").replace(/[��]/g,"").replace(/[\x00-\x1F\x7F-\x9F]/g,"").trim():""}function Bt(){if(!e.chatInitiationMessages||e.chatInitiationMessages.length===0)return null;let t=Math.floor(Math.random()*e.chatInitiationMessages.length),n=e.chatInitiationMessages[t];return n?zt(n):null}function Nt(){return U()||!e.launcher||!e.chatInitiationMessages||e.chatInitiationMessages.length===0?!1:!sessionStorage.getItem(e.initiationShownKey)}function pt(){sessionStorage.setItem(e.initiationShownKey,"true")}function Pt(){let t=Bt();if(!t)return null;let n=document.createElement("div");return n.id=e.initiationMessageId,n.innerHTML=`
|
||||
`;let f=document.getElementById("replypier-styles");f&&f.remove(),document.head.appendChild(w),vt()}function vt(){let t=document.getElementById(e.buttonId);if(!t)return;let n=t.querySelector(".rp-search-placeholder");n&&n.textContent!==e.searchPlaceholder&&(n.textContent=e.searchPlaceholder)}function C(t){if(typeof t!="string")return null;let n=t.trim();return!n||n==="undefined"||n==="null"?null:n}function ct(){return C(window.replypierToken)||C(localStorage.getItem(e.tokenKey))}function lt(t){let n=C(t);if(!n)return null;let i=n.split(".")[1];if(!i)return null;try{let o=JSON.parse(atob(i.replace(/-/g,"+").replace(/_/g,"/")));return o.sub||o.customer_id||null}catch(o){return null}}function J(t){t&&localStorage.setItem(e.tokenKey,t)}function W(){localStorage.removeItem(e.tokenKey)}function H(){return window.innerWidth<=768}function Bt(t){return t?t.replace(/\uFFFD/g,"").replace(/[��]/g,"").replace(/[\x00-\x1F\x7F-\x9F]/g,"").trim():""}function Nt(){if(!e.chatInitiationMessages||e.chatInitiationMessages.length===0)return null;let t=Math.floor(Math.random()*e.chatInitiationMessages.length),n=e.chatInitiationMessages[t];return n?Bt(n):null}function Rt(){return H()||!e.launcher||!e.chatInitiationMessages||e.chatInitiationMessages.length===0?!1:!sessionStorage.getItem(e.initiationShownKey)}function dt(){sessionStorage.setItem(e.initiationShownKey,"true")}function Pt(){let t=Nt();if(!t)return null;let n=document.createElement("div");return n.id=e.initiationMessageId,n.innerHTML=`
|
||||
<span class="initiation-orb"></span>
|
||||
<button class="initiation-close" aria-label="Close"></button>
|
||||
<button class="initiation-close" aria-label="\u5173\u95ED"></button>
|
||||
<p class="initiation-message-text"></p>
|
||||
`,n.dataset.fullMessage=t,n}function Rt(t,n,i=50){let o=t.querySelector(".initiation-message-text");if(!o)return;let h=0,r=setInterval(()=>{h<n.length?(o.textContent+=n.charAt(h),h++):(clearInterval(r),o.classList.add("typing-complete"),t.typeInterval=null)},i);t.typeInterval=r}function Ft(t,n){if(!t||!Nt())return;document.body.appendChild(t),setTimeout(()=>{t.classList.add("show"),setTimeout(()=>{let o=t.dataset.fullMessage;o&&Rt(t,o,40)},300)},1500),t.addEventListener("click",o=>{o.target.classList.contains("initiation-close")||(t.typeInterval&&clearInterval(t.typeInterval),mt(t),n(),pt())});let i=t.querySelector(".initiation-close");i&&i.addEventListener("click",o=>{o.stopPropagation(),t.typeInterval&&clearInterval(t.typeInterval),mt(t),pt()})}function mt(t){t&&(t.classList.remove("show"),setTimeout(()=>{t.parentNode&&t.parentNode.removeChild(t)},400))}function ut(){y=!1,W();let t=document.createElement("div");t.id=e.buttonId,t.classList.add("replypier-pending"),t.innerHTML=`
|
||||
`,n.dataset.fullMessage=t,n}function Ft(t,n,i=50){let o=t.querySelector(".initiation-message-text");if(!o)return;let h=0,r=setInterval(()=>{h<n.length?(o.textContent+=n.charAt(h),h++):(clearInterval(r),o.classList.add("typing-complete"),t.typeInterval=null)},i);t.typeInterval=r}function Ot(t,n){if(!t||!Rt())return;document.body.appendChild(t),setTimeout(()=>{t.classList.add("show"),setTimeout(()=>{let o=t.dataset.fullMessage;o&&Ft(t,o,40)},300)},1500),t.addEventListener("click",o=>{o.target.classList.contains("initiation-close")||(t.typeInterval&&clearInterval(t.typeInterval),ht(t),n(),dt())});let i=t.querySelector(".initiation-close");i&&i.addEventListener("click",o=>{o.stopPropagation(),t.typeInterval&&clearInterval(t.typeInterval),ht(t),dt()})}function ht(t){t&&(t.classList.remove("show"),setTimeout(()=>{t.parentNode&&t.parentNode.removeChild(t)},400))}function pt(){y=!1,O();let t=document.createElement("div");t.id=e.buttonId,t.classList.add("replypier-pending"),t.innerHTML=`
|
||||
<span class="rp-ring"></span>
|
||||
<span class="rp-ring r2"></span>
|
||||
<div class="rp-dots"><span class="rp-dot"></span><span class="rp-dot"></span><span class="rp-dot"></span></div>
|
||||
@@ -878,11 +879,11 @@
|
||||
<span class="rp-search-placeholder"></span>
|
||||
</span>
|
||||
<span class="rp-badge"></span>
|
||||
`,E=setTimeout(F,8e3);let n=document.createElement("div");n.id=e.containerId;let i=document.createElement("div");i.id="replypier-mobile-close",i.innerHTML=`
|
||||
`,E=setTimeout(P,8e3);let n=document.createElement("div");n.id=e.containerId;let i=document.createElement("div");i.id="replypier-mobile-close",i.innerHTML=`
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12H19" stroke="#666" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`;let o=document.createElement("div");o.id="replypier-mobile-topbar",o.innerHTML=`
|
||||
<h3 class="topbar-title">Chat</h3>
|
||||
<h3 class="topbar-title">\u804A\u5929</h3>
|
||||
<div style="width: 44px;"></div>
|
||||
`;let h=document.createElement("div");h.id=e.backdropId,document.body.appendChild(t),document.body.appendChild(h),document.body.appendChild(n),document.body.appendChild(i),document.body.appendChild(o),O(),vt();let r=!1,l=null,w=!1,f=null;function b(){r=!r,n.classList.toggle("active"),h.classList.toggle("active",r),t.classList.toggle("active"),i.classList.toggle("active"),r&&(e.hasOpened=!0,e.unreadCount=0),l&&l.contentWindow&&l.contentWindow.postMessage({type:"WIDGET_VISIBILITY",open:r},"*"),O(),r&&f&&(mt(f),f=null,pt()),U()?r?(t.classList.remove("mobile-closed"),t.style.animation=""):(t.classList.add("mobile-closed"),t.style.animation="replypier-float 4s ease-in-out infinite",o.classList.remove("active"),document.body.classList.remove("ask-anything-mobile")):r&&i.classList.remove("active"),r&&l&&l.contentWindow.postMessage({type:"SCROLL_TO_BOTTOM"},"*"),_(r?"open":"close")}let K=null,bt=!1;function Yt(a){let s=String(a||"").slice(0,2e3);s&&(bt&&l&&l.contentWindow?l.contentWindow.postMessage({type:"PREFILL_MESSAGE",text:s},"*"):K=s)}let D=0;function Ct(){for(D++;n.firstChild;)n.removeChild(n.firstChild);l=null,g=null,T=null,bt=!1,st=!1,K=null,w=!1,t.classList.remove("loading"),P=0,Z()}function Gt(){r&&b(),Ct(),e.unreadCount=0,e.hasOpened=!1,O(),et()}function jt(){r&&b(),Ct(),window.removeEventListener("resize",Tt),window.removeEventListener("message",St),f&&(f.typeInterval&&clearInterval(f.typeInterval),ft(f),f=null),[t,h,n,i,o].forEach(ft),ft(document.getElementById("replypier-styles")),document.body.classList.remove("ask-anything-mobile"),ct=!1,E&&(clearTimeout(E),E=null),lt.length=0,e.unreadCount=0,e.hasOpened=!1,c=null,R.length=0,X=!1,y=!0}function At(a){let s=document.createElement("div");s.className="replypier-error-ui";let I=document.createElement("div");I.className="replypier-error-card";let v=document.createElement("button");v.type="button",v.className="replypier-error-close",v.setAttribute("aria-label","Close chat"),v.textContent="\xD7",v.addEventListener("click",function(){r&&b()}),s.appendChild(v);let u=document.createElement("div");u.className="replypier-error-icon-wrapper";let p=document.createElementNS("http://www.w3.org/2000/svg","svg");p.setAttribute("class","replypier-error-icon"),p.setAttribute("viewBox","0 0 24 24"),p.setAttribute("fill","none"),p.setAttribute("stroke","currentColor"),p.setAttribute("stroke-width","1.5");let k=document.createElementNS("http://www.w3.org/2000/svg","path");k.setAttribute("d","M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z");let $=document.createElementNS("http://www.w3.org/2000/svg","path");$.setAttribute("d","M9 12l2 2 4-4"),p.appendChild(k),p.appendChild($),u.appendChild(p);let A=document.createElement("h2");A.className="replypier-error-title",A.textContent="Chat Unavailable";let yt=document.createElement("p");yt.className="replypier-error-message",yt.textContent=a;let it=document.createElement("div");it.className="replypier-error-footer";let L=document.createElementNS("http://www.w3.org/2000/svg","svg");L.setAttribute("width","14"),L.setAttribute("height","14"),L.setAttribute("viewBox","0 0 60 60"),L.setAttribute("fill","none");let G=document.createElementNS("http://www.w3.org/2000/svg","path");G.setAttribute("d","M18 12 H42 A6 6 0 0 1 48 18 V42 A6 6 0 0 1 42 48 H18 A6 6 0 0 1 12 42 V18 A6 6 0 0 1 18 12 Z M17 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z M26 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z M35 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z"),G.setAttribute("fill-rule","evenodd"),G.setAttribute("fill","currentColor"),G.setAttribute("opacity","0.6"),L.appendChild(G);let z=document.createElement("a");return z.href="https://replypier.site",z.target="_blank",z.rel="noopener noreferrer",z.className="replypier-error-footer-link",z.textContent="Powered by ReplyPier",it.appendChild(L),it.appendChild(z),I.appendChild(u),I.appendChild(A),I.appendChild(yt),I.appendChild(it),s.appendChild(I),s}function et(){return wt(this,null,function*(){if(w||l)return;let a=D;try{w=!0,t.classList.add("loading");let s=dt(),I=`${e.baseUrl}/widgets/${window.replypierId}/data?widget_id=${window.replypierId}`,v={method:"GET",mode:"cors",headers:s?{Authorization:`Bearer ${s}`}:{}};fetch(I,v).then(u=>wt(null,null,function*(){if(a!==D)return null;if(u.status===401)try{let k=(yield u.json()).detail;if(k&&k.code==="identity_expired")return t.classList.remove("loading"),_("identityExpired"),Vt(),null;let $=typeof k=="string"?k:"";if($.includes("generate-token")||$.includes("API key")||$.includes("Token must be obtained")){t.classList.remove("loading"),H();let A=At("This chat widget is not currently configured. Please contact the website administrator to enable chat support.");return n.appendChild(A),F(),null}}catch(p){}if(!u.ok)throw new Error("HTTP error! status: "+u.status);return u.text()})).then(u=>{u!==null&&a===D&&(l=document.createElement("iframe"),l.className="replypier-iframe",l.title="Chat",l.srcdoc=u,l.addEventListener("load",function(){bt=!0,l.contentWindow.postMessage(It(),"*"),l.contentWindow
Line truncated
|
||||
`;let h=document.createElement("div");h.id=e.backdropId,document.body.appendChild(t),document.body.appendChild(h),document.body.appendChild(n),document.body.appendChild(i),document.body.appendChild(o),F(),vt();let r=!1,l=null,w=!1,f=null;function b(){r=!r,n.classList.toggle("active"),h.classList.toggle("active",r),t.classList.toggle("active"),i.classList.toggle("active"),r&&(e.hasOpened=!0,e.unreadCount=0),l&&l.contentWindow&&l.contentWindow.postMessage({type:"WIDGET_VISIBILITY",open:r},"*"),F(),r&&f&&(ht(f),f=null,dt()),H()?r?(t.classList.remove("mobile-closed"),t.style.animation=""):(t.classList.add("mobile-closed"),t.style.animation="replypier-float 4s ease-in-out infinite",o.classList.remove("active"),document.body.classList.remove("ask-anything-mobile")):r&&i.classList.remove("active"),r&&l&&l.contentWindow.postMessage({type:"SCROLL_TO_BOTTOM"},"*"),_(r?"open":"close")}let U=null,ft=!1;function Gt(a){let s=String(a||"").slice(0,2e3);s&&(ft&&l&&l.contentWindow?l.contentWindow.postMessage({type:"PREFILL_MESSAGE",text:s},"*"):U=s)}let K=0;function Ct(){for(K++;n.firstChild;)n.removeChild(n.firstChild);l=null,g=null,T=null,ft=!1,ot=!1,U=null,w=!1,t.classList.remove("loading"),N=0,q()}function jt(){r&&b(),Ct(),e.unreadCount=0,e.hasOpened=!1,F(),Q()}function qt(){r&&b(),Ct(),window.removeEventListener("resize",Tt),window.removeEventListener("message",At),f&&(f.typeInterval&&clearInterval(f.typeInterval),mt(f),f=null),[t,h,n,i,o].forEach(mt),mt(document.getElementById("replypier-styles")),document.body.classList.remove("ask-anything-mobile"),rt=!1,E&&(clearTimeout(E),E=null),st.length=0,e.unreadCount=0,e.hasOpened=!1,c=null,R.length=0,V=!1,y=!0}function St(a){let s=document.createElement("div");s.className="replypier-error-ui";let I=document.createElement("div");I.className="replypier-error-card";let v=document.createElement("button");v.type="button",v.className="replypier-error-close",v.setAttribute("aria-label","\u5173\u95ED\u804A\u5929"),v.textContent="\xD7",v.addEventListener("click",function(){r&&b()}),s.appendChild(v);let u=document.createElement("div");u.className="replypier-error-icon-wrapper";let p=document.createElementNS("http://www.w3.org/2000/svg","svg");p.setAttribute("class","replypier-error-icon"),p.setAttribute("viewBox","0 0 24 24"),p.setAttribute("fill","none"),p.setAttribute("stroke","currentColor"),p.setAttribute("stroke-width","1.5");let k=document.createElementNS("http://www.w3.org/2000/svg","path");k.setAttribute("d","M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z");let $=document.createElementNS("http://www.w3.org/2000/svg","path");$.setAttribute("d","M9 12l2 2 4-4"),p.appendChild(k),p.appendChild($),u.appendChild(p);let S=document.createElement("h2");S.className="replypier-error-title",S.textContent="\u5BA2\u670D\u6682\u4E0D\u53EF\u7528";let bt=document.createElement("p");bt.className="replypier-error-message",bt.textContent=a;let et=document.createElement("div");et.className="replypier-error-footer";let xt=document.createElement("span");xt.innerHTML=_t;let yt=xt.querySelector("svg");yt.setAttribute("width","20"),yt.setAttribute("height","16"),yt.setAttribute("aria-hidden","true");let L=document.createElement("a");return L.href="https://replypier.site",L.target="_blank",L.rel="noopener noreferrer",L.className="replypier-error-footer-link",L.textContent="\u7531 ReplyPier \u63D0\u4F9B\u652F\u6301",et.appendChild(xt),et.appendChild(L),I.appendChild(u),I.appendChild(S),I.appendChild(bt),I.appendChild(et),s.appendChild(I),s}function Q(){return wt(this,null,function*(){if(w||l)return;let a=K;try{w=!0,t.classList.add("loading");let s=ct(),I=`${e.baseUrl}/widgets/${window.replypierId}/data?widget_id=${window.replypierId}`,v={method:"GET",mode:"cors",headers:s?{Authorization:`Bearer ${s}`}:{}};fetch(I,v).then(u=>wt(null,null,function*(){if(a!==K)return null;if(u.status===401)try{let k=(yield u.json()).detail;if(k&&k.code==="identity_expired")return t.classList.remove("loading"),_("identityExpired"),Vt(),null;let $=typeof k=="string"?k:"";if($.includes("generate-token")||$.includes("API key")||$.includes("Token must be obtained")){t.classList.remove("loading"),W();let S=St("\u6B64\u804A\u5929\u7EC4\u4EF6\u5C1A\u672A\u914D\u7F6E\u5B8C\u6210\uFF0C\u8BF7\u8054\u7CFB\u7F51\u7AD9\u7BA1\u7406\u5458\u5F00\u901A\u5728\u7EBF\u5BA2\u670D\u3002");return n.appendChild(S),P(),null}}catch(p){}if(!u.ok)throw new Error("HTTP error! status: "+u.status);return u.text()})).then(u=>{u!==null&&a===K&&(l=document.createElement("iframe"),l.className="replypier-iframe",l.title="\u804A\u5929",l.srcdoc=u,l.addEventListener("load",function(){ft=!0,l.contentWindow.postMessage(It(),"*"),l.contentWindow.postMessage({type:"WIDGET_VISIBILITY",open:r},"*"),U&&(l.contentWindow.postMessage({type:"PREFILL_MESSAGE",text:U},"*"),U=null),ot=!0,D=!1,_("ready")}),n.appendChild(l),g=l.contentWindow,t.classList.remove("loading"),l.style.opacity="1")}).catch(u=>{if(a!==K)return;console.error("Failed to load widget:",u),t.classList.rem
Line truncated
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import BrandMark from '@/components/common/BrandMark.vue'
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
import paperBoat from '@/assets/logo.svg'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ path: '/' }) }))
|
||||
vi.mock('@/components/layout/navItems', () => ({
|
||||
useNavItems: () => ({ navGroups: [] }), navIconSvg: () => '',
|
||||
}))
|
||||
|
||||
describe('paper boat brand mark', () => {
|
||||
it('renders a decorative vector at the requested icon size', () => {
|
||||
const wrapper = mount(BrandMark, { props: { size: 16 } })
|
||||
const image = wrapper.get('img')
|
||||
expect(image.attributes('width')).toBe('16')
|
||||
expect(image.attributes('height')).toBe('16')
|
||||
expect(image.attributes('aria-hidden')).toBe('true')
|
||||
expect(image.attributes('src')).toBe(paperBoat)
|
||||
})
|
||||
|
||||
it('registers the real brand image in both expanded and collapsed sidebars', async () => {
|
||||
const wrapper = mount(AppSidebar, {
|
||||
props: { isCollapsed: false }, global: { stubs: { RouterLink: true } },
|
||||
})
|
||||
expect(wrapper.get('.brand img').attributes('width')).toBe('34')
|
||||
await wrapper.setProps({ isCollapsed: true })
|
||||
expect(wrapper.get('.brand img').attributes('width')).toBe('34')
|
||||
})
|
||||
})
|
||||
@@ -145,7 +145,7 @@ describe('useNavItems', () => {
|
||||
|
||||
it('keeps the sidebar section headings in the More sheet', () => {
|
||||
const { moreNavGroups } = useNavItems()
|
||||
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['主菜单', '设置'])
|
||||
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['接待', '智能', '团队', '配置'])
|
||||
// No empty groups — a section fully covered by the bottom nav is dropped
|
||||
moreNavGroups.value.forEach((g) => expect(g.items.length).toBeGreaterThan(0))
|
||||
})
|
||||
@@ -153,15 +153,15 @@ describe('useNavItems', () => {
|
||||
// User Settings is always visible while the Settings heading used to be
|
||||
// permission-gated separately, which orphaned the item into Main Menu.
|
||||
it('keeps always-visible settings items under Settings for restricted users', () => {
|
||||
// Knowledge, so Main Menu still has a non-primary item to group; no
|
||||
// settings grants, so Settings holds only the always-visible entry.
|
||||
// Knowledge, so 智能 still has a non-primary item to group; no settings
|
||||
// grants, so 配置 holds only the always-visible entry.
|
||||
asUser(['manage_knowledge'])
|
||||
|
||||
const { moreNavGroups } = useNavItems()
|
||||
const settings = moreNavGroups.value.find((g) => g.section === '设置')
|
||||
const settings = moreNavGroups.value.find((g) => g.section === '配置')
|
||||
|
||||
expect(settings?.items.map((i) => i.to)).toEqual(['/settings/user'])
|
||||
expect(moreNavGroups.value.find((g) => g.section === '主菜单')?.items.map((i) => i.to))
|
||||
expect(moreNavGroups.value.find((g) => g.section === '智能')?.items.map((i) => i.to))
|
||||
.not.toContain('/settings/user')
|
||||
})
|
||||
|
||||
@@ -170,8 +170,8 @@ describe('useNavItems', () => {
|
||||
|
||||
const { moreNavGroups, navItems } = useNavItems()
|
||||
|
||||
// Only User Settings survives, so Main Menu disappears entirely
|
||||
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['设置'])
|
||||
expect(navItems.value.filter((i) => i.section).map((i) => i.section)).toEqual(['设置'])
|
||||
// Only User Settings survives, so only 配置 is left
|
||||
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['配置'])
|
||||
expect(navItems.value.filter((i) => i.section).map((i) => i.section)).toEqual(['配置'])
|
||||
})
|
||||
})
|
||||
@@ -27,8 +27,6 @@ interface DashboardLayoutInstance extends ComponentPublicInstance {
|
||||
currentUser: {
|
||||
is_online: boolean
|
||||
}
|
||||
showSettings: boolean
|
||||
showUserMenu: boolean
|
||||
showNotifications: boolean
|
||||
userName: string
|
||||
userRole: string
|
||||
@@ -146,47 +144,28 @@ describe('DashboardLayout', () => {
|
||||
expect(wrapper.classes()).toContain('sidebar-collapsed')
|
||||
})
|
||||
|
||||
it('displays user information correctly', async () => {
|
||||
const vm = wrapper.vm as unknown as DashboardLayoutInstance
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(vm.userName).toBe('Test User')
|
||||
expect(vm.userRole).toBe('Admin')
|
||||
|
||||
// Find name element in the sidebar-open state
|
||||
const userInfo = wrapper.find('.user-info')
|
||||
if (userInfo.exists()) {
|
||||
expect(userInfo.find('.name').text()).toBe('Test User')
|
||||
} else {
|
||||
// If user-info is not visible (responsive design), just verify the data
|
||||
expect(vm.userName).toBe('Test User')
|
||||
}
|
||||
})
|
||||
|
||||
it('shows user menu when clicking profile', async () => {
|
||||
const profileTrigger = wrapper.find('.profile-trigger')
|
||||
expect(wrapper.find('.dropdown-menu').exists()).toBe(false)
|
||||
|
||||
await profileTrigger.trigger('click')
|
||||
expect(wrapper.find('.dropdown-menu').exists()).toBe(true)
|
||||
it('renders the workspace topbar with the current page title', async () => {
|
||||
const topbar = wrapper.find('.topbar')
|
||||
expect(topbar.exists()).toBe(true)
|
||||
expect(topbar.find('.crumb').exists()).toBe(true)
|
||||
// '/' is not a titled page, so the crumb keeps its group but no leaf
|
||||
expect(topbar.find('.crumb').text()).toContain('工作台')
|
||||
})
|
||||
|
||||
it('shows notification badge with correct count', async () => {
|
||||
await wrapper.vm.$nextTick() // Wait for mounted hook
|
||||
const badge = wrapper.find('.notification-badge')
|
||||
const badge = wrapper.find('.topbar .badge-float')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toBe('5')
|
||||
})
|
||||
|
||||
it('toggles online status correctly', async () => {
|
||||
it('toggles online status from the presence control', async () => {
|
||||
const vm = wrapper.vm as unknown as DashboardLayoutInstance
|
||||
const profileTrigger = wrapper.find('.profile-trigger')
|
||||
await profileTrigger.trigger('click')
|
||||
const presence = wrapper.find('.presence')
|
||||
expect(presence.exists()).toBe(true)
|
||||
expect(presence.attributes('data-state')).toBe('online')
|
||||
|
||||
const statusToggle = wrapper.find('.status-toggle')
|
||||
expect(statusToggle.classes()).toContain('online')
|
||||
|
||||
await statusToggle.trigger('click')
|
||||
await presence.trigger('click')
|
||||
expect(vm.currentUser.is_online).toBe(false)
|
||||
})
|
||||
|
||||
@@ -195,16 +174,12 @@ describe('DashboardLayout', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('closes menus when route changes', async () => {
|
||||
it('closes the notification drawer when the route changes', async () => {
|
||||
const vm = wrapper.vm as any
|
||||
// Open menus first
|
||||
vm.showUserMenu = true
|
||||
vm.showNotifications = true
|
||||
|
||||
// Trigger route change
|
||||
await router.push('/new-route')
|
||||
|
||||
expect(vm.showUserMenu).toBe(false)
|
||||
expect(vm.showNotifications).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
getOrbStyleAt, ORB_PALETTE_COUNT, PAPER_AVATAR_NAMES,
|
||||
orbIndexForSeed, orbSvgDataUri, resolveAvatarImage, resolveOrbStyle,
|
||||
terminalMarkSvgDataUri,
|
||||
} from '@/utils/orb'
|
||||
import { useWidgetStyles } from '@/composables/useWidgetStyles'
|
||||
import type { AgentCustomization } from '@/types/widget'
|
||||
|
||||
const decode = (uri: string) => decodeURIComponent(uri.replace('data:image/svg+xml,', ''))
|
||||
|
||||
describe('paper-craft avatars', () => {
|
||||
it('ships ten distinct flat vectors under the existing variant indexes', () => {
|
||||
expect(ORB_PALETTE_COUNT).toBe(10)
|
||||
expect(PAPER_AVATAR_NAMES).toHaveLength(10)
|
||||
const variants = Array.from({ length: 10 }, (_, i) => orbSvgDataUri('support', i))
|
||||
expect(new Set(variants).size).toBe(10)
|
||||
for (const uri of variants) {
|
||||
const svg = new DOMParser().parseFromString(decode(uri), 'image/svg+xml')
|
||||
expect(svg.querySelector('parsererror')).toBeNull()
|
||||
expect(svg.documentElement.getAttribute('viewBox')).toBe('0 0 100 100')
|
||||
expect(svg.querySelector('radialGradient, linearGradient, script, image')).toBeNull()
|
||||
expect(svg.querySelector('path')).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the exact same artwork for CSS avatar slots and img slots', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(getOrbStyleAt(i).background).toContain(orbSvgDataUri('support', i))
|
||||
expect(resolveOrbStyle('support', i)).toEqual(getOrbStyleAt(i))
|
||||
}
|
||||
})
|
||||
|
||||
it('retains stable seeds and normalizes out-of-range stored indexes', () => {
|
||||
expect(orbSvgDataUri('support')).toBe(orbSvgDataUri('', orbIndexForSeed('support')))
|
||||
expect(orbSvgDataUri('', -1)).toBe(orbSvgDataUri('', 9))
|
||||
expect(orbSvgDataUri('', 12)).toBe(orbSvgDataUri('', 2))
|
||||
expect(orbSvgDataUri('', 2.5)).toBe(orbSvgDataUri('', 2))
|
||||
for (const invalid of [NaN, Infinity, '2', null]) {
|
||||
expect(orbSvgDataUri('support', invalid)).toBe(orbSvgDataUri('support'))
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces an old stored orb from metadata, not from the old data URI', () => {
|
||||
expect(resolveAvatarImage('support', {
|
||||
photo_url: 'data:image/svg+xml,old-gradient',
|
||||
customization_metadata: { avatar_style: 'orb', orb_variant: 0 },
|
||||
})).toBe(orbSvgDataUri('support', 0))
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/api/v1/uploads/agents/a.png',
|
||||
'https://cdn.example/a.png?signature=preserved',
|
||||
'data:image/png;base64,customer-photo',
|
||||
'blob:https://example.com/preview',
|
||||
])('preserves an uploaded image: %s', (photo) => {
|
||||
expect(resolveAvatarImage('support', { photo_url: photo })).toBe(photo)
|
||||
expect(resolveAvatarImage('support', {
|
||||
photo_url: photo, customization_metadata: { avatar_style: 'photo', orb_variant: 0 },
|
||||
})).toBe(photo)
|
||||
})
|
||||
|
||||
it('provides a complete avatar when no photo exists', () => {
|
||||
expect(resolveAvatarImage('support')).toBe(orbSvgDataUri('support'))
|
||||
})
|
||||
|
||||
it('retains the terminal option and rejects markup in its color', () => {
|
||||
const terminal = terminalMarkSvgDataUri()
|
||||
expect(decode(terminal)).toContain('#8BB49C')
|
||||
expect(terminalMarkSvgDataUri('\"/><script>alert(1)</script>')).toBe(terminal)
|
||||
expect(resolveAvatarImage('support', {
|
||||
photo_url: terminal, customization_metadata: { avatar_style: 'terminal' },
|
||||
})).toBe(terminal)
|
||||
})
|
||||
|
||||
it('updates the real widget image reactively and preserves its runtime upload origin', () => {
|
||||
const customization = ref<AgentCustomization>({
|
||||
photo_url: 'data:image/svg+xml,old-gradient',
|
||||
customization_metadata: { avatar_style: 'orb', orb_variant: 0 },
|
||||
})
|
||||
const { photoUrl } = useWidgetStyles(customization, ref('support'))
|
||||
expect(photoUrl.value).toBe(orbSvgDataUri('support', 0))
|
||||
customization.value = {
|
||||
photo_url: 'https://cdn.example/customer.png',
|
||||
customization_metadata: { avatar_style: 'photo' },
|
||||
}
|
||||
expect(photoUrl.value).toBe('https://cdn.example/customer.png')
|
||||
})
|
||||
})
|
||||
@@ -128,7 +128,7 @@ describe('LoginView', () => {
|
||||
})
|
||||
|
||||
it('renders login form properly', () => {
|
||||
expect(wrapper.find('.auth-page').exists()).toBe(true)
|
||||
expect(wrapper.find('.login').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="email"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
|
||||
|
||||
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#E3EADD"/><g transform="translate(9 18) scale(1.28)"><path d="M29 20 39 6 42 21Z" fill="#568B73"/><path d="M4 16 32 26 23 43H15Z" fill="#2E6A5C"/><path d="M35 26 60 16 49 43H27Z" fill="#8BB49C"/></g></svg>
|
||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#E3EADD"/><path d="M18 25 47 34V77L18 66Z" fill="#2E6A5C"/><path d="M53 34 82 25V66L53 77Z" fill="#8BB49C"/><path d="M23 20 47 29V36L23 27Z" fill="#F8F4E9"/><path d="M53 29 77 20V27L53 36Z" fill="#FFFDF6"/></svg>
|
||||
|
After Width: | Height: | Size: 312 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#F2E6DC"/><path d="M19 36 31 24 47 24 50 33 53 24H69L81 36V50L50 78 19 50Z" fill="#B45132"/><path d="M50 33 69 24 81 36V50L50 78Z" fill="#D68C65"/></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#E3EADD"/><path d="M23 48H77V78H23Z" fill="#8BB49C"/><path d="M16 48 50 20 84 48Z" fill="#2E6A5C"/><path d="M50 20 84 48H50Z" fill="#568B73"/><path d="M43 58H57V78H43Z" fill="#F4F1EB"/></svg>
|
||||
|
After Width: | Height: | Size: 291 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#233E36"/><g transform="translate(9 18) scale(1.28)"><path d="M29 20 39 6 42 21Z" fill="#F4F1EB"/><path d="M4 16 32 26 23 43H15Z" fill="#DCE8DC"/><path d="M35 26 60 16 49 43H27Z" fill="#8BB49C"/></g></svg>
|
||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#F2E8DC"/><path d="M51 15 77 43 51 72 25 43Z" fill="#D5AE68"/><path d="M51 15V72L25 43Z" fill="#B45132"/><path d="M51 72 48 81 55 87" fill="none" stroke="#8A6326" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 310 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#E8EBDC"/><path d="M25 76C15 40 36 19 78 22 80 60 59 81 25 76Z" fill="#8BB49C"/><path d="M25 76C15 40 36 19 78 22Z" fill="#2E6A5C"/></svg>
|
||||
|
After Width: | Height: | Size: 238 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#F1E8D9"/><path d="M20 31H80V71H20Z" fill="#D6B47C"/><path d="M20 71 50 45 80 71Z" fill="#ECD7AE"/><path d="M20 31 50 55 80 31Z" fill="#2E6A5C"/></svg>
|
||||
|
After Width: | Height: | Size: 251 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#F0E6D8"/><path d="M50 50 26 21 53 29Z" fill="#B45132"/><path d="M50 50 79 26 71 53Z" fill="#D5AE68"/><path d="M50 50 74 79 47 71Z" fill="#8BB49C"/><path d="M50 50 21 74 29 47Z" fill="#2E6A5C"/><circle cx="50" cy="50" r="4" fill="#F4F1EB"/></svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#E5ECE4"/><path d="M17 43 83 22 57 79 45 54Z" fill="#8BB49C"/><path d="M17 43 45 54 83 22Z" fill="#2E6A5C"/><path d="M45 54 57 79 55 48 83 22Z" fill="#568B73"/></svg>
|
||||
|
After Width: | Height: | Size: 266 B |
@@ -29,21 +29,30 @@ html, body {
|
||||
body {
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Display headings */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
/* Headings — 墨与瓷 sets them in the serif face; the smallest two stay sans
|
||||
because they sit inside dense UI where a serif reads as a mistake. */
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-display);
|
||||
color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h5, h6 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Inputs inherit font */
|
||||
input, textarea, select, button {
|
||||
font-family: var(--font-sans);
|
||||
@@ -56,13 +65,8 @@ input::placeholder, textarea::placeholder {
|
||||
|
||||
/* Text selection */
|
||||
::selection {
|
||||
background: #C9F24E;
|
||||
color: #0B0C10;
|
||||
}
|
||||
|
||||
[data-theme="light"] ::selection {
|
||||
background: #4C7A0B;
|
||||
color: #ffffff;
|
||||
background: var(--vermilion);
|
||||
color: var(--seal-fg);
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="20" y="20" width="80" height="80" rx="22" fill="#C9F24E"/>
|
||||
<circle cx="42" cy="60" r="6" fill="#0B0C10"/>
|
||||
<circle cx="60" cy="60" r="6" fill="#0B0C10"/>
|
||||
<circle cx="78" cy="60" r="6" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 48" fill="none"><path d="M29 20 39 6 42 21Z" fill="#568B73"/><path d="M4 16 32 26 23 43H15Z" fill="#2E6A5C"/><path d="M35 26 60 16 49 43H27Z" fill="#8BB49C"/></svg>
|
||||
|
Before Width: | Height: | Size: 324 B After Width: | Height: | Size: 221 B |
@@ -14,41 +14,106 @@
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--text-color);
|
||||
background: var(--background-color);
|
||||
background: var(--paper);
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--text-base);
|
||||
text-rendering: optimizeLegibility;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings: 'ss01', 'cv11';
|
||||
transition: background-color var(--t-med), color var(--t-med);
|
||||
}
|
||||
|
||||
/* Paper grain — the texture that keeps the warm surfaces from looking flat */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: var(--grain-opacity);
|
||||
mix-blend-mode: var(--grain-blend);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--vermilion);
|
||||
color: var(--seal-fg);
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-4xl); }
|
||||
h2 { font-size: var(--text-3xl); }
|
||||
h3 { font-size: var(--text-2xl); }
|
||||
h4 { font-size: var(--text-xl); }
|
||||
h5 { font-size: var(--text-lg); }
|
||||
h6 { font-size: var(--text-base); }
|
||||
h1 {
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
h5 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
h6 {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 0.8;
|
||||
/* Scrollbars — hairlines, not gutters */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--line-2);
|
||||
border-radius: 10px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Focus Styles */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
outline: none;
|
||||
box-shadow: var(--ring);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Helpers shared by every surface */
|
||||
.num {
|
||||
font-family: var(--font-num);
|
||||
font-variant-numeric: lining-nums tabular-nums;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.serif {
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -1,49 +1,135 @@
|
||||
/* ReplyPier Design Tokens — dark-first, lime accent */
|
||||
:root {
|
||||
/* ReplyPier Design Tokens — 墨与瓷 (ink & porcelain)
|
||||
*
|
||||
* Light = 瓷 (porcelain, default), Dark = 夜墨 (night ink).
|
||||
* One accent with meaning: 朱 vermilion = brand + needs attention.
|
||||
* 青 celadon = AI, 金 brass = ratings, 黛 indigo = info. Primary action = ink.
|
||||
*
|
||||
* The lower half of :root maps the legacy token names onto this palette so the
|
||||
* existing components keep working without a per-file rewrite.
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
/* ── Surfaces ─────────────────────────────────────── */
|
||||
--paper: #f4f1eb; /* app background */
|
||||
--paper-2: #ece8e0; /* sidebar, recessed wells */
|
||||
--surface: #fffefb; /* cards, panels */
|
||||
--surface-2: #f9f7f2; /* subtle fills inside cards */
|
||||
--surface-sunk: #efebe4; /* inputs on surface, tracks */
|
||||
|
||||
/* ── Ink scale ────────────────────────────────────── */
|
||||
--ink: #16171a;
|
||||
--ink-2: #33343a;
|
||||
--ink-muted: #6a665f; /* AA 5.0:1 on paper */
|
||||
--ink-faint: #9a958c; /* decorative / placeholder only */
|
||||
|
||||
/* ── Hairlines ────────────────────────────────────── */
|
||||
--line: rgba(22, 23, 26, 0.08);
|
||||
--line-2: rgba(22, 23, 26, 0.14);
|
||||
--line-3: rgba(22, 23, 26, 0.22);
|
||||
|
||||
/* ── Meaningful colour ────────────────────────────── */
|
||||
--vermilion: #b23a22; /* 朱 — brand, waiting, unread, destructive */
|
||||
--celadon: #2e6a5c; /* 青 — AI, online, success */
|
||||
--brass: #8a6326; /* 金 — ratings, premium */
|
||||
--indigo: #3a4b7c; /* 黛 — info, links */
|
||||
|
||||
--vermilion-soft: color-mix(in srgb, var(--vermilion) 8%, transparent);
|
||||
--vermilion-line: color-mix(in srgb, var(--vermilion) 26%, transparent);
|
||||
--celadon-soft: color-mix(in srgb, var(--celadon) 8%, transparent);
|
||||
--celadon-line: color-mix(in srgb, var(--celadon) 24%, transparent);
|
||||
--brass-soft: color-mix(in srgb, var(--brass) 10%, transparent);
|
||||
--brass-line: color-mix(in srgb, var(--brass) 28%, transparent);
|
||||
--indigo-soft: color-mix(in srgb, var(--indigo) 8%, transparent);
|
||||
--indigo-line: color-mix(in srgb, var(--indigo) 24%, transparent);
|
||||
|
||||
/* ── Actions ──────────────────────────────────────── */
|
||||
--primary-bg: var(--ink);
|
||||
--primary-fg: #f7f4ee;
|
||||
--primary-hover: #2a2b30;
|
||||
--seal-fg: #fbf6ee;
|
||||
|
||||
/* ── Message bubbles ──────────────────────────────── */
|
||||
--bubble-visitor: var(--surface);
|
||||
--bubble-ai: color-mix(in srgb, var(--celadon) 6%, var(--surface));
|
||||
--bubble-human: var(--ink);
|
||||
--bubble-human-fg: #f3efe7;
|
||||
|
||||
/* ── Elevation — paper-like, low and warm ─────────── */
|
||||
--elev-1: 0 1px 0 rgba(22, 23, 26, 0.03), 0 1px 2px rgba(22, 23, 26, 0.05);
|
||||
--elev-2: 0 1px 0 rgba(22, 23, 26, 0.03), 0 10px 30px -12px rgba(40, 30, 15, 0.14);
|
||||
--elev-3: 0 30px 80px -24px rgba(40, 30, 15, 0.28);
|
||||
--ring: 0 0 0 3px color-mix(in srgb, var(--ink) 12%, transparent);
|
||||
|
||||
/* ── Type ─────────────────────────────────────────── */
|
||||
--font-serif: 'Noto Serif SC', 'Songti SC', 'STSong', serif;
|
||||
--font-num: 'Fraunces', 'Noto Serif SC', Georgia, serif;
|
||||
--font-sans-cn: 'Hanken Grotesk', 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
--font-mono-cn: 'IBM Plex Mono', ui-monospace, 'SF Mono', monospace;
|
||||
|
||||
/* ── Motion ───────────────────────────────────────── */
|
||||
--ease: cubic-bezier(0.2, 0.7, 0.1, 1);
|
||||
--t-fast: 0.16s var(--ease);
|
||||
--t-med: 0.28s var(--ease);
|
||||
|
||||
/* ── Chart series (validated categorical pair, both modes) ── */
|
||||
--viz-1: #00876c; /* AI */
|
||||
--viz-2: #b5650c; /* 人工 */
|
||||
--viz-grid: rgba(22, 23, 26, 0.07);
|
||||
--viz-axis: #8b867d;
|
||||
|
||||
/* ── Grain overlay ────────────────────────────────── */
|
||||
--grain-opacity: 0.045;
|
||||
--grain-blend: multiply;
|
||||
|
||||
color-scheme: light;
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
Legacy token layer — same names, 墨与瓷 values.
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
/* Surfaces */
|
||||
--bg: #0B0C10;
|
||||
--bg2: #0E0F14;
|
||||
--surface: #13151C;
|
||||
--topbar: rgba(11,12,16,.8);
|
||||
--bg: var(--paper);
|
||||
--bg2: var(--surface);
|
||||
--topbar: color-mix(in srgb, var(--paper) 88%, transparent);
|
||||
--bg-elevated: var(--surface);
|
||||
--bg-deep: var(--paper-2);
|
||||
--scrim: rgba(22, 23, 26, 0.4);
|
||||
--header-gradient: linear-gradient(180deg, var(--paper), var(--paper));
|
||||
|
||||
/* Accent */
|
||||
--accent-ink: #C9F24E; /* accent for text / borders / icons (darkens in light mode) */
|
||||
--accent-solid: #C9F24E; /* accent for FILLED surfaces (stays lime in both themes) */
|
||||
--on-accent-solid: #0B0C10; /* text/icon on --accent-solid (dark in both themes) */
|
||||
--c-lime: #C9F24E;
|
||||
--c-teal: #5FE3D6;
|
||||
--c-purple: #9D8CFF;
|
||||
--c-coral: #FF8A73;
|
||||
/* Accent — the single primary action colour is ink */
|
||||
--accent-ink: var(--ink);
|
||||
--accent-solid: var(--ink);
|
||||
--on-accent-solid: var(--primary-fg);
|
||||
--c-lime: var(--vermilion);
|
||||
--c-teal: var(--celadon);
|
||||
--c-purple: var(--indigo);
|
||||
--c-coral: var(--vermilion);
|
||||
|
||||
/* Contrast inks — text/icon placed on top of a fill */
|
||||
--on-accent: #0B0C10; /* on lime accent */
|
||||
--on-light: #0B0C10; /* on any light fill */
|
||||
--on-dark: #F5F6F8; /* on any dark/coloured fill (theme-invariant) */
|
||||
|
||||
/* Elevated surfaces / header gradient */
|
||||
--bg-elevated: #101119;
|
||||
--bg-deep: #0A0B0E;
|
||||
--header-gradient: linear-gradient(180deg, var(--bg-elevated), var(--bg));
|
||||
--scrim: rgba(6,7,10,.72);
|
||||
/* Contrast inks */
|
||||
--on-accent: var(--primary-fg);
|
||||
--on-light: var(--ink);
|
||||
--on-dark: #f5f6f8;
|
||||
|
||||
/* Mode pills (agent editor) */
|
||||
--mode-ai-bg: #3B82F6;
|
||||
--mode-ai-fg: #FFFFFF;
|
||||
--mode-wf-bg: #9D8CFF;
|
||||
--mode-wf-fg: #0B0C10;
|
||||
--mode-ai-bg: var(--celadon);
|
||||
--mode-ai-fg: #f2faf6;
|
||||
--mode-wf-bg: var(--indigo);
|
||||
--mode-wf-fg: #f4f6fb;
|
||||
--pill-idle-bg: var(--o05);
|
||||
--pill-idle-fg: var(--muted);
|
||||
|
||||
/* Extended status / chart palette */
|
||||
--c-danger: #FF6B6B;
|
||||
--c-warn: #E6A23C;
|
||||
--c-info: #8FB8FF;
|
||||
--c-positive: #7CE38B;
|
||||
--c-neutral: #8A93A3;
|
||||
--c-online: #8FEDE3; /* online label text */
|
||||
--c-pro: #C5BDFF; /* PRO badge text */
|
||||
/* Status / chart palette */
|
||||
--c-danger: var(--vermilion);
|
||||
--c-warn: var(--brass);
|
||||
--c-info: var(--indigo);
|
||||
--c-positive: var(--celadon);
|
||||
--c-neutral: var(--ink-muted);
|
||||
--c-online: var(--celadon);
|
||||
--c-pro: var(--brass);
|
||||
|
||||
/* Theme-aware tints (derived from base palette, auto-adjust per theme) */
|
||||
/* Theme-aware tints */
|
||||
--accent-bg-06: color-mix(in srgb, var(--accent-ink) 6%, transparent);
|
||||
--accent-bg-08: color-mix(in srgb, var(--accent-ink) 8%, transparent);
|
||||
--accent-bg-12: color-mix(in srgb, var(--accent-ink) 12%, transparent);
|
||||
@@ -51,72 +137,73 @@
|
||||
--teal-bg-10: color-mix(in srgb, var(--c-teal) 10%, transparent);
|
||||
--teal-bg: color-mix(in srgb, var(--c-teal) 12%, transparent);
|
||||
--teal-border: color-mix(in srgb, var(--c-teal) 22%, transparent);
|
||||
--purple-bg: color-mix(in srgb, var(--c-purple) 15%, transparent);
|
||||
--purple-border: color-mix(in srgb, var(--c-purple) 28%, transparent);
|
||||
--purple-bg: color-mix(in srgb, var(--c-purple) 12%, transparent);
|
||||
--purple-border: color-mix(in srgb, var(--c-purple) 26%, transparent);
|
||||
--coral-bg: color-mix(in srgb, var(--c-coral) 12%, transparent);
|
||||
--coral-border: color-mix(in srgb, var(--c-coral) 30%, transparent);
|
||||
|
||||
/* Controls */
|
||||
--toggle-knob: #FFFFFF;
|
||||
--toggle-knob: #ffffff;
|
||||
--toggle-track-off: var(--o12);
|
||||
--toggle-on-teal: var(--c-teal);
|
||||
--toggle-on-accent: var(--accent-ink);
|
||||
|
||||
/* Inbox message bubbles */
|
||||
--bubble-customer-bg: var(--o05);
|
||||
--bubble-customer-bg: var(--surface-2);
|
||||
--bubble-customer-fg: var(--text2);
|
||||
--bubble-ai-bg: var(--teal-bg-10);
|
||||
--bubble-ai-border: var(--teal-border);
|
||||
--bubble-ai-bg: var(--bubble-ai);
|
||||
--bubble-ai-border: var(--celadon-line);
|
||||
--bubble-ai-fg: var(--text2);
|
||||
--bubble-agent-bg: var(--accent-ink);
|
||||
--bubble-agent-fg: var(--on-accent);
|
||||
--bubble-agent-bg: var(--bubble-human);
|
||||
--bubble-agent-fg: var(--bubble-human-fg);
|
||||
|
||||
/* Brand gradients / avatars */
|
||||
--grad-purple-teal: linear-gradient(135deg, var(--c-purple), var(--c-teal));
|
||||
--grad-lime-teal: linear-gradient(135deg, var(--accent-ink), var(--c-teal));
|
||||
--grad-generate: linear-gradient(135deg, var(--c-purple), #7C6AE6);
|
||||
--grad-purple-teal: linear-gradient(135deg, var(--indigo), var(--celadon));
|
||||
--grad-lime-teal: linear-gradient(135deg, var(--ink), var(--celadon));
|
||||
--grad-generate: linear-gradient(135deg, var(--indigo), color-mix(in srgb, var(--indigo) 70%, var(--ink)));
|
||||
|
||||
/* Text scale */
|
||||
--text: #F5F6F8;
|
||||
--text2: #E7EAF0;
|
||||
--text3: #C7CCD6;
|
||||
--muted: #9CA3B0;
|
||||
/* Small text on every surface (down to #1A1C23) must reach WCAG AA 4.5:1. */
|
||||
--muted2: #868D9C;
|
||||
--faint: #7F8696;
|
||||
--code: #D7DBE3;
|
||||
--text: var(--ink);
|
||||
--text2: var(--ink-2);
|
||||
--text3: color-mix(in srgb, var(--ink) 82%, var(--paper));
|
||||
--muted: var(--ink-muted);
|
||||
--muted2: color-mix(in srgb, var(--ink-muted) 82%, var(--paper));
|
||||
--faint: var(--ink-faint);
|
||||
--code: var(--ink-2);
|
||||
|
||||
/* White-opacity tokens — borders, dividers, overlays */
|
||||
--o03: rgba(255,255,255,.03);
|
||||
--o04: rgba(255,255,255,.04);
|
||||
--o05: rgba(255,255,255,.05);
|
||||
--o06: rgba(255,255,255,.06);
|
||||
--o07: rgba(255,255,255,.07);
|
||||
--o08: rgba(255,255,255,.08);
|
||||
--o10: rgba(255,255,255,.1);
|
||||
--o12: rgba(255,255,255,.12);
|
||||
--o14: rgba(255,255,255,.14);
|
||||
--o16: rgba(255,255,255,.16);
|
||||
--o20: rgba(255,255,255,.2);
|
||||
--o25: rgba(255,255,255,.25);
|
||||
--o30: rgba(255,255,255,.3);
|
||||
/* White-opacity tokens → ink-opacity tokens (they are borders/overlays) */
|
||||
--o03: color-mix(in srgb, var(--ink) 3%, transparent);
|
||||
--o04: color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
--o05: color-mix(in srgb, var(--ink) 5%, transparent);
|
||||
--o06: color-mix(in srgb, var(--ink) 6%, transparent);
|
||||
--o07: color-mix(in srgb, var(--ink) 7.5%, transparent);
|
||||
--o08: color-mix(in srgb, var(--ink) 8%, transparent);
|
||||
--o10: color-mix(in srgb, var(--ink) 10%, transparent);
|
||||
--o12: color-mix(in srgb, var(--ink) 12%, transparent);
|
||||
--o14: color-mix(in srgb, var(--ink) 14%, transparent);
|
||||
--o16: color-mix(in srgb, var(--ink) 16%, transparent);
|
||||
--o20: color-mix(in srgb, var(--ink) 20%, transparent);
|
||||
--o25: color-mix(in srgb, var(--ink) 25%, transparent);
|
||||
--o30: color-mix(in srgb, var(--ink) 30%, transparent);
|
||||
|
||||
/* Semantic */
|
||||
--success-color: #0f9d6e;
|
||||
--error-color: #DC2626;
|
||||
--error-dark: #b91c1c;
|
||||
--warning-color: #D97706;
|
||||
--info-color: #2563EB;
|
||||
--success-bg: rgba(15,157,110,.1);
|
||||
--error-bg: rgba(220,38,38,.1);
|
||||
--warning-bg: rgba(217,119,6,.1);
|
||||
--info-bg: rgba(37,99,235,.1);
|
||||
--success-color: var(--celadon);
|
||||
--error-color: var(--vermilion);
|
||||
--error-dark: #8f2c18;
|
||||
--warning-color: var(--brass);
|
||||
--info-color: var(--indigo);
|
||||
--success-bg: var(--celadon-soft);
|
||||
--error-bg: var(--vermilion-soft);
|
||||
--warning-bg: var(--brass-soft);
|
||||
--info-bg: var(--indigo-soft);
|
||||
|
||||
/* Fonts */
|
||||
--font-family: 'Instrument Sans', system-ui, -apple-system, sans-serif;
|
||||
--font-sans: 'Instrument Sans', system-ui, -apple-system, sans-serif;
|
||||
--font-display: 'Space Grotesk', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-family: var(--font-sans-cn);
|
||||
--font-sans: var(--font-sans-cn);
|
||||
/* Legacy alias: 121 call sites use it for headings *and* small labels, so it
|
||||
stays sans. Titles that should be serif use --font-serif explicitly. */
|
||||
--font-display: var(--font-sans-cn);
|
||||
--font-mono: var(--font-mono-cn);
|
||||
|
||||
/* Font weights */
|
||||
--font-weight-normal: 400;
|
||||
@@ -125,7 +212,7 @@
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Letter spacing */
|
||||
--tracking-display: -0.02em;
|
||||
--tracking-display: 0.02em;
|
||||
--tracking-tight: -0.01em;
|
||||
--tracking-wide: 0.04em;
|
||||
|
||||
@@ -149,34 +236,35 @@
|
||||
--space-3xl: 4rem;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 16px;
|
||||
--radius-sm: 7px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-full: 9999px;
|
||||
/* Design-spec radii */
|
||||
--radius-chip: 10px;
|
||||
--radius-btn: 11px;
|
||||
--radius-input: 12px;
|
||||
--radius-card: 18px;
|
||||
--radius-chip: 8px;
|
||||
--radius-btn: 10px;
|
||||
--radius-input: 10px;
|
||||
--radius-card: 14px;
|
||||
--radius-card-lg: 20px;
|
||||
--radius-pill: 999px;
|
||||
--radius-xl: 20px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.18s cubic-bezier(0.4,0,0.2,1);
|
||||
--transition-normal: 0.25s cubic-bezier(0.4,0,0.2,1);
|
||||
--transition-slow: 0.4s cubic-bezier(0.4,0,0.2,1);
|
||||
--transition-fast: var(--t-fast);
|
||||
--transition-normal: var(--t-med);
|
||||
--transition-slow: 0.4s var(--ease);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.15);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,.2);
|
||||
--shadow-lg: 0 20px 50px rgba(0,0,0,.5);
|
||||
--shadow-sm: var(--elev-1);
|
||||
--shadow-md: var(--elev-2);
|
||||
--shadow-lg: var(--elev-3);
|
||||
|
||||
/* Focus ring */
|
||||
--ring-focus: 0 0 0 3px rgba(201,242,78,.15);
|
||||
--ring-focus: var(--ring);
|
||||
|
||||
/* Icon filters (for SVG img elements on dark bg) */
|
||||
--icon-filter: brightness(0) invert(1);
|
||||
--icon-opacity: 0.5;
|
||||
/* Icon filters (for SVG img elements) */
|
||||
--icon-filter: brightness(1);
|
||||
--icon-opacity: 0.55;
|
||||
--icon-active-opacity: 0.9;
|
||||
|
||||
/* Mobile app shell */
|
||||
@@ -187,15 +275,14 @@
|
||||
/* Overlay stacking order — every fixed overlay must use one of these */
|
||||
--z-topbar: 50;
|
||||
--z-bottom-nav: 900;
|
||||
--z-drawer: 1000; /* notification drawer, sidebar overlay */
|
||||
--z-fullscreen-page: 1050; /* mobile chat-info "Details" page */
|
||||
--z-prompt: 1090; /* push/install prompts */
|
||||
--z-sheet: 1100; /* More bottom sheet */
|
||||
--z-support-chat: 1150; /* hosted support chat drawer (read from JS) */
|
||||
--z-drawer: 1000;
|
||||
--z-fullscreen-page: 1050;
|
||||
--z-prompt: 1090;
|
||||
--z-sheet: 1100;
|
||||
--z-support-chat: 1150;
|
||||
--z-modal: 2000;
|
||||
|
||||
/* Breakpoints — keep in sync with useBreakpoint.ts (JS can't read these
|
||||
at media-query time: --breakpoint-sm = MOBILE, --breakpoint-md = TABLET) */
|
||||
/* Breakpoints — keep in sync with useBreakpoint.ts */
|
||||
--breakpoint-xs: 480px;
|
||||
--breakpoint-sm: 768px;
|
||||
--breakpoint-md: 1024px;
|
||||
@@ -206,7 +293,7 @@
|
||||
|
||||
/* Legacy aliases (keep for component compatibility) */
|
||||
--primary-color: var(--accent-ink);
|
||||
--primary-dark: #b3d943;
|
||||
--primary-dark: var(--ink-2);
|
||||
--background-color: var(--bg);
|
||||
--background-base: var(--surface);
|
||||
--background-soft: var(--bg2);
|
||||
@@ -221,120 +308,96 @@
|
||||
--text-muted: var(--muted);
|
||||
--text-placeholder: var(--faint);
|
||||
--code-bg: var(--surface);
|
||||
--primary-soft: rgba(201,242,78,.1);
|
||||
--primary-soft: var(--accent-bg-08);
|
||||
|
||||
/* Legacy aliases used by the agent editor (resolve via flipping tokens) */
|
||||
/* Legacy aliases used by the agent editor */
|
||||
--accent-color: var(--accent-ink);
|
||||
--background-alt: var(--bg2);
|
||||
--background-muted: var(--o05);
|
||||
--radius-xl: 20px;
|
||||
--success: var(--success-color);
|
||||
--success-dark: var(--success-color);
|
||||
--success-light: color-mix(in srgb, var(--success-color) 14%, transparent);
|
||||
--success-color-soft: color-mix(in srgb, var(--success-color) 12%, transparent);
|
||||
--success-dark: var(--celadon);
|
||||
--success-light: var(--success-bg);
|
||||
--success-color-soft: var(--success-bg);
|
||||
--warning: var(--warning-color);
|
||||
--warning-light: color-mix(in srgb, var(--warning-color) 14%, transparent);
|
||||
--error-light: var(--coral-bg);
|
||||
--error-soft: var(--coral-bg);
|
||||
--error-color-soft: var(--coral-bg);
|
||||
--warning-light: var(--warning-bg);
|
||||
--error-light: var(--error-bg);
|
||||
--error-soft: var(--error-bg);
|
||||
--error-color-soft: var(--error-bg);
|
||||
--primary-color-filter: none;
|
||||
}
|
||||
|
||||
/* Light theme override */
|
||||
[data-theme="light"] {
|
||||
--bg: #EEF0F4;
|
||||
--bg2: #FFFFFF;
|
||||
--surface: #FFFFFF;
|
||||
--topbar: rgba(255,255,255,.85);
|
||||
/* ── 夜墨 ─────────────────────────────────────────────── */
|
||||
[data-theme='dark'] {
|
||||
--paper: #121211;
|
||||
--paper-2: #0c0c0b;
|
||||
--surface: #1a1917;
|
||||
--surface-2: #201f1c;
|
||||
--surface-sunk: #151413;
|
||||
|
||||
/* Accent and status inks are used as text on white and on their own pale
|
||||
tints: darkened until they reach WCAG AA 4.5:1 on both. */
|
||||
--accent-ink: #44700A;
|
||||
--c-lime: #44700A;
|
||||
--c-teal: #097171;
|
||||
--c-purple: #6454BF;
|
||||
--c-coral: #AC4829;
|
||||
--warning-color: #9C5604;
|
||||
--ink: #ede7dc;
|
||||
--ink-2: #cfc8bc;
|
||||
--ink-muted: #a09a8f;
|
||||
--ink-faint: #6f6a62;
|
||||
|
||||
--text: #171A21;
|
||||
--text2: #222732;
|
||||
--text3: #3C424E;
|
||||
--muted: #5C6473;
|
||||
/* Small text on every surface (down to #EEF0F4) must reach WCAG AA 4.5:1. */
|
||||
--muted2: #626977;
|
||||
--faint: #646B79;
|
||||
--code: #3C424E;
|
||||
--line: rgba(237, 231, 220, 0.08);
|
||||
--line-2: rgba(237, 231, 220, 0.14);
|
||||
--line-3: rgba(237, 231, 220, 0.22);
|
||||
|
||||
--o03: rgba(17,21,30,.03);
|
||||
--o04: rgba(17,21,30,.04);
|
||||
--o05: rgba(17,21,30,.05);
|
||||
--o06: rgba(17,21,30,.06);
|
||||
--o07: rgba(17,21,30,.075);
|
||||
--o08: rgba(17,21,30,.08);
|
||||
--o10: rgba(17,21,30,.09);
|
||||
--o12: rgba(17,21,30,.1);
|
||||
--o14: rgba(17,21,30,.11);
|
||||
--o16: rgba(17,21,30,.12);
|
||||
--o20: rgba(17,21,30,.14);
|
||||
--o25: rgba(17,21,30,.18);
|
||||
--o30: rgba(17,21,30,.2);
|
||||
--vermilion: #e2603f;
|
||||
--celadon: #74b9a6;
|
||||
--brass: #d3aa62;
|
||||
--indigo: #9aabdd;
|
||||
|
||||
/* Icon filters for light theme — SVGs are already dark, no inversion needed */
|
||||
--icon-filter: brightness(1);
|
||||
--icon-opacity: 0.6;
|
||||
--icon-active-opacity: 0.85;
|
||||
--primary-bg: #ede7dc;
|
||||
--primary-fg: #121211;
|
||||
--primary-hover: #ffffff;
|
||||
--seal-fg: #fff7ee;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(17,21,30,.05);
|
||||
--shadow-md: 0 4px 12px rgba(17,21,30,.08);
|
||||
--shadow-lg: 0 20px 50px rgba(17,21,30,.15);
|
||||
--bubble-ai: color-mix(in srgb, var(--celadon) 9%, var(--surface));
|
||||
--bubble-human: #ede7dc;
|
||||
--bubble-human-fg: #151413;
|
||||
|
||||
--ring-focus: 0 0 0 3px rgba(76,122,11,.2);
|
||||
--primary-soft: rgba(76,122,11,.08);
|
||||
--elev-1: 0 1px 0 rgba(0, 0, 0, 0.3);
|
||||
--elev-2: 0 1px 0 rgba(255, 255, 255, 0.02) inset, 0 14px 34px -14px rgba(0, 0, 0, 0.7);
|
||||
--elev-3: 0 30px 90px -20px rgba(0, 0, 0, 0.85);
|
||||
--ring: 0 0 0 3px color-mix(in srgb, var(--ink) 18%, transparent);
|
||||
|
||||
/* Contrast inks — accent is dark-olive in light, so text on it must be white */
|
||||
--on-accent: #FFFFFF;
|
||||
--on-light: #0B0C10;
|
||||
--on-dark: #F5F6F8;
|
||||
--viz-1: #1e9e80;
|
||||
--viz-2: #b97c1c;
|
||||
--viz-grid: rgba(237, 231, 220, 0.07);
|
||||
--viz-axis: #8d887f;
|
||||
|
||||
/* Elevated surfaces / scrim (gradients & tints referencing base tokens auto-recompute) */
|
||||
--bg-elevated: #FFFFFF;
|
||||
--bg-deep: #F4F6FA;
|
||||
--scrim: rgba(17,21,30,.4);
|
||||
--grain-opacity: 0.06;
|
||||
--grain-blend: screen;
|
||||
|
||||
/* Mode pills — darker fills need light text in light theme */
|
||||
--mode-ai-bg: #2563EB;
|
||||
--mode-ai-fg: #FFFFFF;
|
||||
--mode-wf-bg: #6D5BD0;
|
||||
--mode-wf-fg: #FFFFFF;
|
||||
color-scheme: dark;
|
||||
|
||||
/* Extended status / chart palette tuned for light bg: each reaches WCAG AA
|
||||
4.5:1 as text on white and on its own pale tint. */
|
||||
--c-danger: #B33F3F;
|
||||
--c-warn: #8F5E18;
|
||||
--c-info: #225BD8;
|
||||
--c-positive: #187742;
|
||||
--c-neutral: #606773;
|
||||
--c-online: #097171;
|
||||
--c-pro: #6454BF;
|
||||
/* Legacy overrides that cannot derive from the base palette */
|
||||
--on-accent: var(--primary-fg);
|
||||
--on-light: #121211;
|
||||
--on-dark: #f5f6f8;
|
||||
|
||||
/* Generate gradient — darker end stop for light */
|
||||
--grad-generate: linear-gradient(135deg, var(--c-purple), #5B49C9);
|
||||
--mode-ai-bg: var(--celadon);
|
||||
--mode-ai-fg: #10201c;
|
||||
--mode-wf-bg: var(--indigo);
|
||||
--mode-wf-fg: #12151d;
|
||||
|
||||
/* Legacy aliases */
|
||||
--primary-color: var(--accent-ink);
|
||||
--primary-dark: #3d6209;
|
||||
--background-color: var(--bg);
|
||||
--background-base: var(--surface);
|
||||
--toggle-knob: #121211;
|
||||
--grad-purple-teal: linear-gradient(135deg, var(--indigo), var(--celadon));
|
||||
--grad-lime-teal: linear-gradient(135deg, var(--ink), var(--celadon));
|
||||
--grad-generate: linear-gradient(135deg, var(--indigo), color-mix(in srgb, var(--indigo) 70%, #ffffff));
|
||||
|
||||
--text3: color-mix(in srgb, var(--ink) 82%, var(--paper));
|
||||
--muted2: color-mix(in srgb, var(--ink-muted) 82%, var(--paper));
|
||||
|
||||
--error-dark: #f08a72;
|
||||
--background-soft: var(--bg2);
|
||||
--background-mute: var(--o08);
|
||||
--hover-bg: var(--o05);
|
||||
--border-color: var(--o10);
|
||||
--border-color-hover: var(--o16);
|
||||
--text-primary: var(--text);
|
||||
--text-secondary: var(--text3);
|
||||
--text-color: var(--text);
|
||||
--text-heading: var(--text);
|
||||
--text-muted: var(--muted);
|
||||
--text-placeholder: var(--faint);
|
||||
--code-bg: var(--surface);
|
||||
--header-gradient: linear-gradient(180deg, var(--paper), var(--paper));
|
||||
|
||||
--icon-filter: brightness(0) invert(1);
|
||||
--icon-opacity: 0.5;
|
||||
--icon-active-opacity: 0.9;
|
||||
|
||||
--primary-dark: var(--ink-2);
|
||||
--success-dark: var(--celadon);
|
||||
}
|
||||
@@ -15,10 +15,11 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import BrandMark from '@/components/common/BrandMark.vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
|
||||
import type { AgentCustomization } from '@/types/agent'
|
||||
import { resolveUploadUrl } from '@/config/api'
|
||||
import { orbSvgDataUri, resolveOrbStyle } from '@/utils/orb'
|
||||
import { resolveAvatarImage, resolveOrbStyle } from '@/utils/orb'
|
||||
import { themeCssVars } from '@/webclient/widget-theme'
|
||||
import '@/webclient/widget-surface.css'
|
||||
import { useAgentChat } from '@/composables/useAgentChat'
|
||||
@@ -327,23 +328,9 @@ const messageNameStyles = computed(() => ({
|
||||
color: 'var(--rp-text)'
|
||||
}))
|
||||
|
||||
const photoUrl = computed(() => {
|
||||
// These are <img> slots in chat bubbles, so the fallback has to be an image:
|
||||
// the orb as an SVG data URI. Previously the retired /avatars/*.svg artwork.
|
||||
// Seeded off orbSeed (the agent slug) so it matches the orb on the detail
|
||||
// header and in the list, which use the same seed.
|
||||
if (!props.customization.photo_url) {
|
||||
return orbSvgDataUri(
|
||||
props.orbSeed || props.agentName,
|
||||
props.customization.customization_metadata?.orb_variant,
|
||||
)
|
||||
}
|
||||
|
||||
// Absolute S3/CDN URLs pass through — the API signs photo_url itself, so
|
||||
// there is no separate signed field to prefer. Local paths are resolved
|
||||
// against the runtime API origin.
|
||||
return resolveUploadUrl(props.customization.photo_url)
|
||||
})
|
||||
const photoUrl = computed(() => resolveUploadUrl(
|
||||
resolveAvatarImage(props.orbSeed || props.agentName, props.customization),
|
||||
))
|
||||
|
||||
// Function to format message content
|
||||
const formatMessage = (content: string) => {
|
||||
@@ -372,7 +359,7 @@ const isAskAnythingStyle = computed(() => {
|
||||
return props.customization.chat_style === 'ASK_ANYTHING' || isAuroraStyle.value
|
||||
})
|
||||
|
||||
// Show the generated aurora orb when the user explicitly picked "orb", or as the Aurora
|
||||
// Show the paper-craft avatar when the user explicitly picked "orb", or as the Aurora
|
||||
// fallback when no profile photo is set. A selected profile picture always takes precedence.
|
||||
const orbMeta = computed(() => props.customization.customization_metadata as Record<string, unknown> | undefined)
|
||||
const useOrbAvatar = computed(() => {
|
||||
@@ -382,7 +369,7 @@ const useOrbAvatar = computed(() => {
|
||||
return isAuroraStyle.value && !props.customization.photo_url
|
||||
})
|
||||
|
||||
const orbStyle = computed(() => resolveOrbStyle(props.agentName, orbMeta.value?.orb_variant))
|
||||
const orbStyle = computed(() => resolveOrbStyle(props.orbSeed || props.agentName, orbMeta.value?.orb_variant))
|
||||
|
||||
// Computed property for container styles
|
||||
const containerStyles = computed(() => {
|
||||
@@ -607,12 +594,7 @@ const handleInitiationClick = () => {
|
||||
|
||||
<!-- Powered by footer for welcome message -->
|
||||
<div class="powered-by-welcome" :style="messageNameStyles">
|
||||
<svg class="replypier-logo" width="15" height="15" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo" :size="20" />
|
||||
<span class="rp-powered-prefix">技术支持:</span><strong class="rp-brand">ReplyPier</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -710,12 +692,7 @@ const handleInitiationClick = () => {
|
||||
|
||||
<!-- Add powered by footer -->
|
||||
<div class="powered-by" :style="messageNameStyles">
|
||||
<svg class="replypier-logo" width="15" height="15" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo" :size="20" />
|
||||
<span class="rp-powered-prefix">技术支持:</span><strong class="rp-brand">ReplyPier</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +149,7 @@ const chatStyleOptions = [
|
||||
{
|
||||
value: 'AURORA' as ChatStyle,
|
||||
label: '极光',
|
||||
description: '全新的随便问——深色主题,配发光极光球头像。',
|
||||
description: '深色随便问主题,可搭配纸艺头像。',
|
||||
group: 'new' as const,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useRouter } from 'vue-router'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import type { AgentWithCustomization, AgentCustomization } from '@/types/agent'
|
||||
import { getApiUrl, getWidgetUrl, resolveUploadUrl } from '@/config/api'
|
||||
import { ORB_PALETTE_COUNT, getOrbStyleAt, resolveOrbStyle, orbSvgDataUri, terminalMarkSvgDataUri } from '@/utils/orb'
|
||||
import { ORB_PALETTE_COUNT, PAPER_AVATAR_NAMES, getOrbStyleAt, resolveOrbStyle, orbSvgDataUri, terminalMarkSvgDataUri } from '@/utils/orb'
|
||||
|
||||
import KnowledgeExplorer from '@/components/knowledge/KnowledgeExplorer.vue'
|
||||
import AgentCustomizationView from './AgentCustomizationView.vue'
|
||||
@@ -64,7 +64,7 @@ const toggleAvatarPicker = () => {
|
||||
if (isUploading.value) return
|
||||
showAvatarPicker.value = !showAvatarPicker.value
|
||||
}
|
||||
// Aurora orb avatar (generated, no photo). Stored in customization_metadata so no
|
||||
// Paper-craft avatar (legacy orb storage key). Stored in customization_metadata so no
|
||||
// schema change is needed. Selecting a real picture resets this back to 'photo'.
|
||||
const orbCount = ORB_PALETTE_COUNT
|
||||
const orbMeta = computed(
|
||||
@@ -104,7 +104,7 @@ const selectOrbAvatar = async (variant: number) => {
|
||||
if (isUploading.value) return
|
||||
closeAvatarPicker()
|
||||
try {
|
||||
// Store the orb as an SVG data URI in photo_url so it propagates everywhere
|
||||
// Store the paper-craft avatar as an SVG data URI in photo_url so it propagates everywhere
|
||||
// (agent list, widget header, previews) via the normal avatar path.
|
||||
const orbUrl = orbSvgDataUri(agentData.value.name || '', variant)
|
||||
await persistAvatarMeta({ avatar_style: 'orb', orb_variant: variant }, orbUrl)
|
||||
@@ -118,7 +118,7 @@ const selectTerminalMark = async () => {
|
||||
if (isUploading.value) return
|
||||
closeAvatarPicker()
|
||||
try {
|
||||
// Tint the ">" mark with the agent's accent (falls back to the Terminal lime).
|
||||
// Tint the ">" mark with the agent's accent (falls back to the muted celadon).
|
||||
const accent = (agentData.value.customization?.accent_color as string | undefined) || undefined
|
||||
const markUrl = terminalMarkSvgDataUri(accent)
|
||||
await persistAvatarMeta({ avatar_style: 'terminal' }, markUrl)
|
||||
@@ -134,7 +134,7 @@ const selectPresetAvatar = async (url: string) => {
|
||||
optimisticPhoto.value = url
|
||||
try {
|
||||
await applyPresetAvatar(url)
|
||||
// A real picture was chosen — turn off the orb if it was on.
|
||||
// A real picture was chosen — turn off the paper-craft avatar if it was on.
|
||||
if (useOrbAvatar.value) await persistAvatarMeta({ avatar_style: 'photo' })
|
||||
} finally {
|
||||
optimisticPhoto.value = null
|
||||
@@ -873,8 +873,8 @@ onMounted(async () => {
|
||||
:class="{ active: useOrbAvatar && (currentOrbVariant ?? -1) === n - 1 }"
|
||||
:disabled="isUploading"
|
||||
@click="selectOrbAvatar(n - 1)"
|
||||
:aria-label="`光球 ${n}`"
|
||||
:title="`光球 ${n}`"
|
||||
:aria-label="PAPER_AVATAR_NAMES[n - 1]"
|
||||
:title="PAPER_AVATAR_NAMES[n - 1]"
|
||||
>
|
||||
<span class="avatar-orb-thumb" :style="getOrbStyleAt(n - 1)"></span>
|
||||
</button>
|
||||
@@ -887,7 +887,7 @@ onMounted(async () => {
|
||||
aria-label="终端标记"
|
||||
title="Terminal >"
|
||||
>
|
||||
<span class="avatar-terminal-thumb">></span>
|
||||
<img :src="terminalMarkSvgDataUri()" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="avatar-picker-upload" :disabled="isUploading" @click="chooseUploadAvatar">
|
||||
@@ -1515,7 +1515,7 @@ onMounted(async () => {
|
||||
box-shadow: 0 0 0 2px var(--accent-bg-12);
|
||||
}
|
||||
|
||||
/* Aurora orb tile in the picker grid */
|
||||
/* Paper-craft tile; class names retain existing selectors. */
|
||||
.avatar-pick-orb {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -1525,22 +1525,6 @@ onMounted(async () => {
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* Terminal ">" prompt mark tile in the picker grid */
|
||||
.avatar-terminal-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #0b0c10;
|
||||
box-shadow: inset 0 0 0 2px rgba(201, 242, 78, 0.55);
|
||||
color: #c9f24e;
|
||||
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.avatar-pick-orb.active {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: 0 0 0 2px var(--accent-bg-12);
|
||||
@@ -1620,7 +1604,7 @@ onMounted(async () => {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Generated aurora orb shown in place of the avatar image */
|
||||
/* Generated paper-craft artwork shown in place of an uploaded photo. */
|
||||
.agent-orb-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -542,7 +542,7 @@ onMounted(() => {
|
||||
|
||||
.view-details-btn {
|
||||
background-color: var(--accent-solid);
|
||||
color: white;
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import paperBoat from '../../assets/logo.svg'
|
||||
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 20 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="paperBoat" :width="size" :height="size" alt="" aria-hidden="true">
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
img { display: inline-block; vertical-align: middle; flex-shrink: 0; object-fit: contain; }
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import type { Teammate } from '@/services/users'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
interface FilterValues {
|
||||
customerEmailFilter: string
|
||||
@@ -121,27 +122,25 @@ onBeforeUnmount(() => {
|
||||
<div class="filter-header-section">
|
||||
<!-- Filter Toggle Button -->
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('toggle')"
|
||||
class="filter-toggle-btn"
|
||||
:class="{ active: showFilters, 'has-filters': hasActiveFilters }"
|
||||
aria-label="筛选"
|
||||
:aria-expanded="showFilters"
|
||||
aria-label="筛选会话"
|
||||
title="筛选会话"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||
</svg>
|
||||
<span class="filter-glyph" v-html="navIconSvg('filter', 16)"></span>
|
||||
<span v-if="hasActiveFilters" class="filter-count">{{ activeFilterCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Advanced Filters Dropdown -->
|
||||
<div v-if="showFilters" class="filters-container">
|
||||
<div v-if="showFilters" class="filters-container" @click.self="emit('toggle')">
|
||||
<div class="filters-dropdown">
|
||||
<div class="filters-header">
|
||||
<h3>筛选会话</h3>
|
||||
<button @click="emit('toggle')" class="close-btn" aria-label="关闭筛选">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<button type="button" @click="emit('toggle')" class="close-btn" aria-label="关闭筛选">
|
||||
<span v-html="navIconSvg('x', 15)"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -209,8 +208,8 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<button @click="applyFilters" class="apply-btn">应用筛选</button>
|
||||
<button @click="clearFilters" class="clear-btn">清除全部</button>
|
||||
<button type="button" @click="clearFilters" class="btn btn-ghost btn-sm clear-btn">清除全部</button>
|
||||
<button type="button" @click="applyFilters" class="btn btn-primary btn-sm apply-btn">应用筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,75 +219,99 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
.filter-header-section {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-toggle-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-muted);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
background-color var(--t-fast),
|
||||
color var(--t-fast);
|
||||
}
|
||||
|
||||
.filter-glyph {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.filter-glyph :deep(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter-toggle-btn:hover {
|
||||
background: var(--background-soft);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--primary-color-soft);
|
||||
border-color: var(--line-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.filter-toggle-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
|
||||
.filter-toggle-btn.active {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-color: var(--primary-color);
|
||||
background: var(--ink);
|
||||
border-color: var(--ink);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
.filter-toggle-btn.has-filters {
|
||||
background: var(--primary-color-soft);
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
.filter-toggle-btn.has-filters:not(.active) {
|
||||
border-color: var(--line-3);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
min-width: 17px;
|
||||
height: 17px;
|
||||
padding: 0 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--ink);
|
||||
color: var(--primary-fg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
box-shadow: 0 0 0 2px var(--paper);
|
||||
}
|
||||
|
||||
/* Opens from the filter button towards the thread. The width is capped so the
|
||||
panel still fits a 769px viewport with the queue at its 300px minimum. */
|
||||
.filters-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
top: calc(100% + 10px);
|
||||
left: 0;
|
||||
right: auto;
|
||||
z-index: 1000;
|
||||
background: var(--background-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
animation: slideDown 0.2s ease-out;
|
||||
min-width: 600px;
|
||||
max-width: 90vw;
|
||||
width: 520px;
|
||||
max-width: calc(100vw - 24px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-md);
|
||||
animation: filterDrop var(--t-fast);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
@keyframes filterDrop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -297,169 +320,154 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.filters-dropdown {
|
||||
padding: var(--space-lg);
|
||||
padding: 20px 22px 18px;
|
||||
}
|
||||
|
||||
.filters-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-md);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filters-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--t-fast),
|
||||
color var(--t-fast);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 12px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
transition: all var(--transition-fast);
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
box-shadow var(--t-fast);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 8px center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 16px;
|
||||
padding-right: 40px;
|
||||
cursor: pointer;
|
||||
.filter-input:hover {
|
||||
border-color: var(--line-3);
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-color-soft);
|
||||
border-color: var(--line-3);
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
|
||||
.filter-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.filter-input:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Native selects keep the platform chevron, which follows color-scheme in
|
||||
both themes — a hard-coded data-URI arrow would not. */
|
||||
.filter-select {
|
||||
appearance: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.apply-btn, .clear-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.apply-btn:hover {
|
||||
background: var(--primary-color-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: var(--background-color);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: var(--background-soft);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
/* Responsive: a full-screen sheet on phones */
|
||||
@media (max-width: 768px) {
|
||||
.filters-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
inset: 0;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--scrim);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.filters-dropdown {
|
||||
background: var(--background-color);
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: 86vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.apply-btn, .clear-btn {
|
||||
.apply-btn,
|
||||
.clear-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,14 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import type { Conversation, ChatDetail } from '@/types/chat'
|
||||
import ConversationChat from '@/components/conversations/ConversationChat.vue'
|
||||
import ChannelBadge from '@/components/common/ChannelBadge.vue'
|
||||
import HandlerBadge from '@/components/conversations/HandlerBadge.vue'
|
||||
import { useConversationsList } from '@/composables/useConversationsList'
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint'
|
||||
import { chatHandler } from '@/utils/chatState'
|
||||
import { userService } from '@/services/user'
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { getInitials } from '@/utils/text'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
conversations: Conversation[]
|
||||
@@ -52,22 +53,39 @@ const emit = defineEmits<{
|
||||
const { isMobile } = useBreakpoint()
|
||||
const currentUserId = userService.getUserId()
|
||||
|
||||
// The preview used to be prefixed with the AI agent's name unconditionally,
|
||||
// which read as "the bot said this" even on a chat a colleague had taken over.
|
||||
// Anonymous visitors have neither a name nor a real email (the backend hides the
|
||||
// placeholder), so the row would otherwise show nothing at all.
|
||||
const customerName = (conv: Conversation) =>
|
||||
conv.customer?.full_name || conv.customer?.email || '访客'
|
||||
|
||||
// Display labels for the backend status enum, which stays in English in the API.
|
||||
const statusLabels: Record<string, string> = {
|
||||
open: '进行中',
|
||||
closed: '已结束',
|
||||
transferred: '已转交',
|
||||
pending: '等待中'
|
||||
const initials = (conv: Conversation) => getInitials(customerName(conv), '访')
|
||||
|
||||
// Handler state colours the row: a queued visitor is the one thing that should
|
||||
// catch the eye in the queue, so that row takes 朱砂; a claimed chat takes ink.
|
||||
const avatarTone = (conv: Conversation) => {
|
||||
const kind = chatHandler(conv, currentUserId).kind
|
||||
return kind === 'waiting' ? 'vermilion' : kind === 'human' ? 'ink' : ''
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => statusLabels[status] || status
|
||||
const isWaiting = (conv: Conversation) => chatHandler(conv, currentUserId).kind === 'waiting'
|
||||
|
||||
// Human-readable channel names for the quiet chip on each row. The widget is
|
||||
// the default context, so it gets the plain label rather than a brand one.
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
web: '网页',
|
||||
shopify: 'Shopify',
|
||||
whatsapp: 'WhatsApp',
|
||||
messenger: 'Messenger',
|
||||
instagram: 'Instagram',
|
||||
telegram: 'Telegram',
|
||||
slack: 'Slack',
|
||||
email: '邮件',
|
||||
sms: '短信',
|
||||
line: 'LINE',
|
||||
api: 'API'
|
||||
}
|
||||
const channelLabel = (channel?: string | null) =>
|
||||
(channel && CHANNEL_LABELS[channel]) || channel || '网页'
|
||||
|
||||
const previewPrefix = (conv: Conversation) => {
|
||||
const handler = chatHandler(conv, currentUserId)
|
||||
@@ -157,10 +175,14 @@ watch(() => props.statusFilter, () => {
|
||||
filterChanged.value = true
|
||||
})
|
||||
|
||||
// Watch for loading state changes to select first conversation after filter change
|
||||
// Watch for loading state changes. Two things happen when a load finishes:
|
||||
// a filter switch opens the top of the new queue, and the first desktop paint
|
||||
// opens the top row so the thread pane is never blank on arrival.
|
||||
watch(() => props.loading, (isLoading, prevLoading) => {
|
||||
// Only proceed if loading has just completed (was true, now false)
|
||||
if (prevLoading === true && isLoading === false && filterChanged.value) {
|
||||
if (prevLoading !== true || isLoading !== false) return
|
||||
|
||||
if (filterChanged.value) {
|
||||
// Reset the flag
|
||||
filterChanged.value = false
|
||||
|
||||
@@ -173,6 +195,18 @@ watch(() => props.loading, (isLoading, prevLoading) => {
|
||||
selectedChat.value = null
|
||||
selectedId.value = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// First paint: open the top of the queue on desktop. A deep link, a live
|
||||
// selection or the mobile list pane all opt out.
|
||||
if (
|
||||
!isMobile.value &&
|
||||
!props.initialSessionId &&
|
||||
!selectedId.value &&
|
||||
props.conversations.length > 0
|
||||
) {
|
||||
loadChatDetailWithSelection(props.conversations[0].session_id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -252,37 +286,25 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<div class="conversations-container"
|
||||
:class="{ 'with-chat-info': showChatInfo, 'mobile-show-chat': mobilePane === 'chat' }">
|
||||
<!-- Sidebar with conversation list -->
|
||||
<div class="conversations-sidebar">
|
||||
<!-- Filter controls -->
|
||||
<div class="filter-controls">
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: statusFilter === 'open' }"
|
||||
@click="emit('updateFilter', 'open')"
|
||||
>
|
||||
进行中
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: statusFilter === 'closed' }"
|
||||
@click="emit('updateFilter', 'closed')"
|
||||
>
|
||||
已结束
|
||||
</button>
|
||||
<!-- Queue column -->
|
||||
<aside class="conversations-sidebar" aria-label="会话队列">
|
||||
<div v-if="loading && !loadingMore" class="state">
|
||||
<span class="state-spinner" aria-hidden="true"></span>
|
||||
<p class="state-title">加载会话中…</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !loadingMore" class="loading-state">
|
||||
加载会话中…
|
||||
<div v-else-if="error" class="state">
|
||||
<p class="state-title">加载失败</p>
|
||||
<p class="state-note">{{ error }}</p>
|
||||
<button class="btn btn-ghost btn-sm" @click="emit('refresh')">重试</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
{{ error }}
|
||||
<button @click="emit('refresh')" class="refresh-button">重试</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="conversations.length === 0" class="empty-state">
|
||||
暂无会话
|
||||
<div v-else-if="conversations.length === 0" class="state">
|
||||
<span class="state-glyph" v-html="navIconSvg('inbox', 22)"></span>
|
||||
<p class="state-title">暂无会话</p>
|
||||
<p class="state-note">
|
||||
{{ statusFilter === 'closed' ? '还没有已结束的会话。' : '新的访客会话会出现在这里。' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="conversations-list" ref="listContainer" tabindex="0" aria-label="会话列表">
|
||||
@@ -290,7 +312,7 @@ onBeforeUnmount(() => {
|
||||
v-for="conv in formattedConversations"
|
||||
:key="conv.session_id"
|
||||
class="conversation-item"
|
||||
:class="{ active: selectedId === conv.session_id }"
|
||||
:class="{ active: selectedId === conv.session_id, waiting: isWaiting(conv) }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`与 ${customerName(conv)} 的会话`"
|
||||
@@ -299,34 +321,32 @@ onBeforeUnmount(() => {
|
||||
@keydown.enter.prevent="handleConversationClick(conv.session_id)"
|
||||
@keydown.space.prevent="handleConversationClick(conv.session_id)"
|
||||
>
|
||||
<div class="conversation-item-header">
|
||||
<h3>{{ customerName(conv) }}</h3>
|
||||
<ChannelBadge :channel="conv.channel" />
|
||||
<span class="timestamp">{{ conv.timeAgo }}</span>
|
||||
<span class="avatar" :class="avatarTone(conv)">{{ initials(conv) }}</span>
|
||||
|
||||
<div class="conv-main">
|
||||
<div class="conv-row">
|
||||
<b>{{ customerName(conv) }}</b>
|
||||
<time class="num">{{ conv.timeAgo }}</time>
|
||||
</div>
|
||||
<div class="conversation-preview">
|
||||
<span class="agent-name">{{ previewPrefix(conv) }}:</span>
|
||||
|
||||
<p class="conv-preview">
|
||||
<em>{{ previewPrefix(conv) }}:</em>
|
||||
<!-- Product message preview -->
|
||||
<template v-if="conv.message_type === 'product' && conv.shopify_output?.products?.length">
|
||||
<p class="last-message product-preview">
|
||||
<span class="product-icon">🛍️</span>
|
||||
已分享 {{ conv.shopify_output.products.length }} 件商品
|
||||
</p>
|
||||
</template>
|
||||
<!-- Regular message preview -->
|
||||
<template v-else>
|
||||
<p class="last-message">{{ conv.last_message }}</p>
|
||||
</template>
|
||||
<div v-if="unreadMessages[conv.session_id]" class="unread-bubble">
|
||||
{{ unreadMessages[conv.session_id] }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-count">{{ conv.message_count }} 条消息</div>
|
||||
<div class="conversation-footer">
|
||||
<div class="conversation-status" :class="conv.status">
|
||||
{{ statusLabel(conv.status) }}
|
||||
</div>
|
||||
<template v-else>{{ conv.last_message || '暂无消息' }}</template>
|
||||
</p>
|
||||
|
||||
<div class="conv-tags">
|
||||
<HandlerBadge :chat="conv" :current-user-id="currentUserId" />
|
||||
<span class="chip chip-quiet">{{ channelLabel(conv.channel) }}</span>
|
||||
<span class="conv-count num">{{ conv.message_count }} 条</span>
|
||||
<span v-if="unreadMessages[conv.session_id]" class="unread num">
|
||||
{{ unreadMessages[conv.session_id] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -337,18 +357,18 @@ onBeforeUnmount(() => {
|
||||
ref="loadMoreTrigger"
|
||||
>
|
||||
<div v-if="loadingMore" class="loading-more">
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="state-spinner state-spinner--sm" aria-hidden="true"></span>
|
||||
<span>加载更多会话中…</span>
|
||||
</div>
|
||||
<div v-else-if="hasMore" class="load-more-container">
|
||||
<span class="load-more-hint">向下滚动加载更多</span>
|
||||
<button
|
||||
class="load-more-button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
@click="emit('loadMore')"
|
||||
:disabled="loadingMore"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
<span class="load-more-hint">或继续向下滚动</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,20 +376,20 @@ onBeforeUnmount(() => {
|
||||
<!-- Scroll to top button -->
|
||||
<button
|
||||
v-if="showScrollToTop"
|
||||
class="scroll-to-top-btn"
|
||||
class="scroll-to-top-btn icon-btn bordered"
|
||||
@click="scrollToTop"
|
||||
aria-label="回到顶部"
|
||||
title="回到顶部"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 15l-6-6-6 6"/>
|
||||
</svg>
|
||||
<span class="scroll-glyph" v-html="navIconSvg('chevronRight', 15)"></span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Chat view -->
|
||||
<!-- Thread column -->
|
||||
<div class="chat-view">
|
||||
<div v-if="chatLoading" class="loading-state">
|
||||
加载会话中…
|
||||
<div v-if="chatLoading" class="state">
|
||||
<span class="state-spinner" aria-hidden="true"></span>
|
||||
<p class="state-title">加载会话中…</p>
|
||||
</div>
|
||||
<ConversationChat
|
||||
v-else-if="selectedChat"
|
||||
@@ -392,6 +412,11 @@ onBeforeUnmount(() => {
|
||||
@back="emit('back')"
|
||||
@info="emit('info')"
|
||||
/>
|
||||
<div v-else class="chat-empty">
|
||||
<span class="empty-glyph" v-html="navIconSvg('message', 24)"></span>
|
||||
<p class="empty-title">选择一段会话</p>
|
||||
<p class="empty-note">选中后即可查看完整对话并回复访客。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -399,25 +424,22 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
.conversations-container {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
/* Same as the parent grid: without a bounded row the sidebar's height: 100%
|
||||
resolves against content, so .conversations-list can never scroll. */
|
||||
grid-template-columns: var(--queue-w, 344px) minmax(0, 1fr);
|
||||
/* Without a bounded row the sidebar's height: 100% resolves against content,
|
||||
so .conversations-list can never scroll. */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
min-height: 0;
|
||||
background: var(--paper);
|
||||
color: var(--text);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.conversations-container.with-chat-info {
|
||||
grid-template-columns: 320px 1fr;
|
||||
}
|
||||
|
||||
.conversations-sidebar {
|
||||
border-right: 1px solid var(--o08);
|
||||
background: var(--bg2);
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -426,98 +448,179 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
background: var(--bg2);
|
||||
gap: 4px;
|
||||
/* The queue heading is drawn by ConversationsView above this pane, so the
|
||||
scrolling list starts underneath it. Scoped to desktop because the heading
|
||||
joins the normal flow on phones. */
|
||||
@media (min-width: 769px) {
|
||||
.conversations-sidebar {
|
||||
padding-top: var(--queue-head-h, 124px);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
flex: 1;
|
||||
padding: 7px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--o05);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--o10);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
padding: 6px 12px 20px;
|
||||
margin: 0;
|
||||
scrollbar-width: thin; /* For Firefox */
|
||||
scrollbar-color: var(--border-color) transparent; /* For Firefox */
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Webkit scrollbar styling */
|
||||
.conversations-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.conversations-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.conversations-list::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Status and handler sit on one line and wrap together on a narrow row. */
|
||||
.conversation-footer {
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
gap: 12px;
|
||||
padding: 14px 12px;
|
||||
border-radius: var(--radius-card);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--t-fast),
|
||||
box-shadow var(--t-fast);
|
||||
}
|
||||
|
||||
.conversation-item + .conversation-item {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: color-mix(in srgb, var(--surface) 58%, transparent);
|
||||
}
|
||||
|
||||
.conversation-item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm), 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.conversation-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 16px;
|
||||
bottom: 16px;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
/* Waiting visitors are the queue's one urgent row — 朱砂 marks it. */
|
||||
.conversation-item.waiting {
|
||||
background: var(--vermilion-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--vermilion-line);
|
||||
}
|
||||
|
||||
.conversation-item.waiting::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 16px;
|
||||
bottom: 16px;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--vermilion);
|
||||
}
|
||||
|
||||
.conversation-item.waiting.active {
|
||||
background: var(--vermilion-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--vermilion-line), var(--shadow-sm);
|
||||
}
|
||||
|
||||
.conversation-item .avatar {
|
||||
--a: 38px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.conv-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-status {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
.conv-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.conv-row b {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conversation-status.open {
|
||||
background: rgba(201,242,78,.12);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.conversation-status.closed {
|
||||
background: var(--o06);
|
||||
.conv-row time {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conversation-status.transferred {
|
||||
background-color: var(--warning-color-soft);
|
||||
color: var(--warning-color);
|
||||
.conv-preview {
|
||||
margin-top: 3px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.conv-preview em {
|
||||
font-style: normal;
|
||||
color: var(--ink-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conv-tags {
|
||||
margin-top: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.conv-tags .chip {
|
||||
height: 21px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.conv-count {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unread {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--ink);
|
||||
color: var(--primary-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Chat view */
|
||||
.chat-view {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@@ -528,245 +631,154 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--o06);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: var(--o05);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: var(--accent-bg-06);
|
||||
border-left: 3px solid var(--accent-ink);
|
||||
}
|
||||
|
||||
.conversation-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.conversation-item-header h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 170px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.conversation-preview {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.last-message {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.message-count {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.unread-bubble {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-radius: 50%;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.no-chat-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
/* Loading / error / empty — one centred column in the queue */
|
||||
.state {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
padding: 4px 8px;
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
.state-glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.state-note {
|
||||
max-width: 24ch;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state .btn {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.state-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--line-2);
|
||||
border-top-color: var(--ink);
|
||||
animation: queue-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.state-spinner--sm {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
|
||||
@keyframes queue-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.empty-note {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.load-more-trigger {
|
||||
height: 40px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
margin: 8px 0;
|
||||
padding: 12px 0 8px;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.load-more-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.load-more-button {
|
||||
padding: 6px 12px;
|
||||
background-color: var(--background-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.load-more-button:hover {
|
||||
background-color: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.load-more-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
font-size: 11.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.scroll-to-top-btn {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: all var(--transition-fast);
|
||||
bottom: 18px;
|
||||
right: 18px;
|
||||
z-index: 10;
|
||||
box-shadow: var(--shadow-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.scroll-to-top-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
.scroll-glyph {
|
||||
display: inline-flex;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.scroll-to-top-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.product-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.last-message.product-preview {
|
||||
color: var(--primary-color);
|
||||
max-width: 160px;
|
||||
.scroll-glyph :deep(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Mobile: single stacked pane — the list by default, the chat when a session
|
||||
@@ -774,7 +786,7 @@ onBeforeUnmount(() => {
|
||||
@media (max-width: 768px) {
|
||||
.conversations-container,
|
||||
.conversations-container.with-chat-info {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.conversations-container .chat-view {
|
||||
@@ -793,23 +805,21 @@ onBeforeUnmount(() => {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
padding: 4px 10px calc(24px + var(--safe-bottom));
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 14px 16px;
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.conversation-item-header h3 {
|
||||
max-width: none;
|
||||
.conv-row b {
|
||||
font-size: 15px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
max-width: none;
|
||||
flex: 1;
|
||||
.conv-tags .chip {
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -16,20 +16,14 @@ limitations under the License.
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { chatHandler, type ChatHandlerKind, type HandlerCandidate } from '@/utils/chatState'
|
||||
import { chatHandler, type HandlerCandidate } from '@/utils/chatState'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
const props = defineProps<{
|
||||
chat?: HandlerCandidate | null
|
||||
currentUserId?: string | null
|
||||
}>()
|
||||
|
||||
const HANDLER_ICONS: Record<ChatHandlerKind, string> = {
|
||||
ai: 'fa-solid fa-robot',
|
||||
waiting: 'fa-solid fa-user-clock',
|
||||
human: 'fa-solid fa-user',
|
||||
closed: 'fa-solid fa-lock',
|
||||
}
|
||||
|
||||
const handler = computed(() => chatHandler(props.chat, props.currentUserId))
|
||||
|
||||
// A closed chat has nobody handling it, and the status pill beside this badge
|
||||
@@ -38,25 +32,29 @@ const visible = computed(() => handler.value.kind !== 'closed')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="visible" class="handler-badge" :class="`handler-${handler.kind}`">
|
||||
<font-awesome-icon :icon="HANDLER_ICONS[handler.kind]" />
|
||||
<span
|
||||
v-if="visible"
|
||||
class="handler-badge chip"
|
||||
:class="[
|
||||
`handler-${handler.kind}`,
|
||||
handler.kind === 'ai' ? 'chip-ai' : handler.kind === 'waiting' ? 'chip-wait' : '',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="handler.kind === 'human'"
|
||||
class="handler-icon"
|
||||
v-html="navIconSvg('headset', 12)"
|
||||
></span>
|
||||
<i v-else class="d"></i>
|
||||
<span class="handler-label">{{ handler.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The shared .chip pill owns the shape; these classes only recolor it for the
|
||||
three handler states. Names are kept for the component tests. */
|
||||
.handler-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The name can be long; the badge shrinks rather than pushing the row wider. */
|
||||
@@ -65,18 +63,30 @@ const visible = computed(() => handler.value.kind !== 'closed')
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.handler-icon {
|
||||
display: inline-flex;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.handler-icon :deep(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.handler-ai {
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
background: var(--celadon-soft);
|
||||
border-color: var(--celadon-line);
|
||||
color: var(--celadon);
|
||||
}
|
||||
|
||||
.handler-waiting {
|
||||
background: var(--warning-color-soft);
|
||||
color: var(--warning-color);
|
||||
background: var(--vermilion-soft);
|
||||
border-color: var(--vermilion-line);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.handler-human {
|
||||
background: var(--success-color-soft);
|
||||
color: var(--success-color);
|
||||
background: transparent;
|
||||
border-color: var(--line-2);
|
||||
color: var(--text2);
|
||||
}
|
||||
</style>
|
||||
@@ -16,21 +16,22 @@ limitations under the License.
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
// The channel's delivery receipt for a message we sent. 'failed' has its own
|
||||
// "Not delivered" notice, so it shows no tick.
|
||||
type ChannelStatus = 'sent' | 'delivered' | 'read'
|
||||
|
||||
const TICKS: Record<ChannelStatus, { icon: string; label: string }> = {
|
||||
sent: { icon: 'fa-solid fa-check', label: '已发送' },
|
||||
delivered: { icon: 'fa-solid fa-check-double', label: '已送达' },
|
||||
read: { icon: 'fa-solid fa-check-double', label: '已读' },
|
||||
sent: { icon: 'check', label: '已发送' },
|
||||
delivered: { icon: 'checks', label: '已送达' },
|
||||
read: { icon: 'checks', label: '已读' },
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
status?: string | null
|
||||
// On an accent-filled bubble no blue holds contrast in either theme, so
|
||||
// read becomes an inverted badge in the bubble's own colour pair
|
||||
// the tick inherits the bubble's own foreground instead.
|
||||
onAccent?: boolean
|
||||
}>()
|
||||
|
||||
@@ -48,26 +49,40 @@ const tick = computed(() =>
|
||||
:aria-label="tick.label"
|
||||
role="img"
|
||||
>
|
||||
<font-awesome-icon :icon="tick.icon" />
|
||||
<span class="tick-icon" v-html="navIconSvg(tick.icon, 13)"></span>
|
||||
<span class="tick-label">{{ tick.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-ticks {
|
||||
display: inline-flex;
|
||||
margin-left: 4px;
|
||||
font-size: var(--text-xs);
|
||||
vertical-align: baseline;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--celadon);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-ticks--read {
|
||||
color: var(--c-info);
|
||||
.tick-icon {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.tick-icon :deep(svg) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* A sent message is not a success yet — keep it quiet until it lands. */
|
||||
.status-ticks--sent {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-ticks--on-accent {
|
||||
color: color-mix(in srgb, var(--bubble-human-fg) 78%, transparent);
|
||||
}
|
||||
|
||||
.status-ticks--on-accent.status-ticks--read {
|
||||
padding: 0 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bubble-agent-fg);
|
||||
color: var(--bubble-agent-bg);
|
||||
color: var(--bubble-human-fg);
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,8 @@ limitations under the License.
|
||||
|
||||
<script setup lang="ts">
|
||||
// A small icon + label above a message saying where it came from (a tapped
|
||||
// button, an external app).
|
||||
// button, an external app). The icon name stays a Font Awesome string: the
|
||||
// message surface passes the same tokens it always has.
|
||||
defineProps<{
|
||||
icon: string
|
||||
label: string
|
||||
@@ -33,9 +34,20 @@ defineProps<{
|
||||
.message-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
font-size: var(--text-xs);
|
||||
gap: 5px;
|
||||
margin-bottom: 7px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-tag :deep(svg) {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { AddSourcePayload, CrawlScope } from '@/composables/useKnowledgeExplorer'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
type SourceKind = 'website' | 'sitemap' | 'pdf' | 'text'
|
||||
|
||||
@@ -40,11 +41,11 @@ const content = ref('')
|
||||
const dragOver = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const types: { key: SourceKind; title: string; sub: string }[] = [
|
||||
{ key: 'website', title: '网站', sub: '抓取单个页面或整个站点' },
|
||||
{ key: 'sitemap', title: '站点地图', sub: '索引其中列出的所有页面' },
|
||||
{ key: 'pdf', title: '文档', sub: 'PDF 文件,最大 25 MB' },
|
||||
{ key: 'text', title: '文本', sub: '直接粘贴内容' },
|
||||
const types: { key: SourceKind; title: string; sub: string; icon: string }[] = [
|
||||
{ key: 'website', title: '网站', sub: '抓取单个页面或整个站点', icon: 'globe' },
|
||||
{ key: 'sitemap', title: '站点地图', sub: '索引其中列出的所有页面', icon: 'link' },
|
||||
{ key: 'pdf', title: '文档', sub: 'PDF 文件,最大 25 MB', icon: 'file' },
|
||||
{ key: 'text', title: '文本', sub: '直接粘贴内容', icon: 'edit' },
|
||||
]
|
||||
|
||||
const willQueue = computed(() => kind.value !== 'text')
|
||||
@@ -157,9 +158,8 @@ function submit() {
|
||||
<h3 class="modal__title">添加知识来源</h3>
|
||||
<p class="modal__sub">选择来源类型,我们会在后台抓取并建立索引。</p>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" aria-label="关闭" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||
<button class="icon-btn sm modal__close" type="button" aria-label="关闭" @click="emit('close')">
|
||||
<span v-html="navIconSvg('x', 16, 'icon-sm')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -170,10 +170,14 @@ function submit() {
|
||||
type="button"
|
||||
class="type"
|
||||
:class="{ 'type--active': kind === t.key }"
|
||||
:aria-pressed="kind === t.key"
|
||||
@click="kind = t.key"
|
||||
>
|
||||
<span class="type__ico" v-html="navIconSvg(t.icon, 16, 'icon')"></span>
|
||||
<span class="type__text">
|
||||
<span class="type__title">{{ t.title }}</span>
|
||||
<span class="type__sub">{{ t.sub }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -181,14 +185,28 @@ function submit() {
|
||||
<!-- website -->
|
||||
<template v-if="kind === 'website'">
|
||||
<label class="field-label" for="kb-add-url">页面 URL</label>
|
||||
<input id="kb-add-url" v-model="url" class="text-input" type="text"
|
||||
placeholder="https://docs.yourcompany.com/help" @keyup.enter="submit" />
|
||||
<input
|
||||
id="kb-add-url"
|
||||
v-model="url"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="https://docs.yourcompany.com/help"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
|
||||
<label class="field-label">抓取范围</label>
|
||||
<div class="scope">
|
||||
<button v-for="opt in scopes" :key="opt.key" type="button" class="scope__opt"
|
||||
:class="{ 'scope__opt--active': scope === opt.key }" @click="scope = opt.key">
|
||||
<button
|
||||
v-for="opt in scopes"
|
||||
:key="opt.key"
|
||||
type="button"
|
||||
class="scope__opt"
|
||||
:class="{ 'scope__opt--active': scope === opt.key }"
|
||||
:aria-pressed="scope === opt.key"
|
||||
@click="scope = opt.key"
|
||||
>
|
||||
<span class="radio" :class="{ 'radio--on': scope === opt.key }"></span>
|
||||
<span>
|
||||
<span class="scope__text">
|
||||
<span class="scope__title">{{ opt.title }}</span>
|
||||
<span class="scope__sub">{{ opt.sub }}</span>
|
||||
</span>
|
||||
@@ -202,10 +220,17 @@ function submit() {
|
||||
<!-- sitemap -->
|
||||
<template v-else-if="kind === 'sitemap'">
|
||||
<label class="field-label" for="kb-add-sitemap">站点地图 URL</label>
|
||||
<input id="kb-add-sitemap" v-model="url" class="text-input" type="text"
|
||||
placeholder="https://yourcompany.com/sitemap.xml" @keyup.enter="submit" />
|
||||
<div class="note note--teal">
|
||||
我们会读取站点地图,并将其中列出的每个页面加入抓取队列。较大的站点地图会分批处理。
|
||||
<input
|
||||
id="kb-add-sitemap"
|
||||
v-model="url"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="https://yourcompany.com/sitemap.xml"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<div class="note note--info">
|
||||
<span class="note__ico" v-html="navIconSvg('link', 14, 'icon-sm')"></span>
|
||||
<span>我们会读取站点地图,并将其中列出的每个页面加入抓取队列。较大的站点地图会分批处理。</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -214,29 +239,51 @@ function submit() {
|
||||
<div
|
||||
class="drop"
|
||||
:class="{ 'drop--over': dragOver }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="pickFiles"
|
||||
@keyup.enter="pickFiles"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave.prevent="dragOver = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<input ref="fileInput" type="file" accept="application/pdf" multiple class="hidden-file"
|
||||
@change="onFilesSelected" />
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
class="hidden-file"
|
||||
@change="onFilesSelected"
|
||||
/>
|
||||
<span class="drop__ico" v-html="navIconSvg('upload', 20, 'icon')"></span>
|
||||
<span v-if="files.length" class="drop__name">{{ files.map((f) => f.name).join(', ') }}</span>
|
||||
<span v-else class="drop__name">拖入 PDF 或点击选择文件</span>
|
||||
<span class="drop__hint">PDF 文件 · 每个最大 25 MB</span>
|
||||
</div>
|
||||
<div class="note note--coral">
|
||||
我们会提取文本、切分成多个章节,并逐一建立索引。扫描版 PDF 会经过 OCR 处理。
|
||||
<div class="note note--warn">
|
||||
<span class="note__ico" v-html="navIconSvg('file', 14, 'icon-sm')"></span>
|
||||
<span>我们会提取文本、切分成多个章节,并逐一建立索引。扫描版 PDF 会经过 OCR 处理。</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- text -->
|
||||
<template v-else>
|
||||
<label class="field-label" for="kb-add-title">标题</label>
|
||||
<input id="kb-add-title" v-model="title" class="text-input" type="text" placeholder="例如:退款政策" />
|
||||
<input
|
||||
id="kb-add-title"
|
||||
v-model="title"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="例如:退款政策"
|
||||
/>
|
||||
|
||||
<label class="field-label" for="kb-add-content">内容</label>
|
||||
<textarea id="kb-add-content" v-model="content" class="textarea"
|
||||
placeholder="粘贴或输入希望智能体学习的内容…"></textarea>
|
||||
<textarea
|
||||
id="kb-add-content"
|
||||
v-model="content"
|
||||
class="form-input textarea"
|
||||
placeholder="粘贴或输入希望智能体学习的内容…"
|
||||
></textarea>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -247,8 +294,10 @@ function submit() {
|
||||
<template v-else>立即添加,无需抓取。</template>
|
||||
</span>
|
||||
<div class="foot__actions">
|
||||
<button class="btn btn--ghost" type="button" :disabled="submitting" @click="emit('close')">取消</button>
|
||||
<button class="btn btn--primary" type="button" :disabled="!canSubmit" @click="submit">
|
||||
<button class="btn btn-ghost" type="button" :disabled="submitting" @click="emit('close')">
|
||||
取消
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" :disabled="!canSubmit" @click="submit">
|
||||
{{ submitting ? '添加中…' : submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -261,150 +310,143 @@ function submit() {
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
padding: 48px 20px;
|
||||
overflow-y: auto;
|
||||
background: var(--scrim);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 560px;
|
||||
width: 600px;
|
||||
max-width: 100%;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--o08);
|
||||
padding: 22px 26px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: var(--text);
|
||||
margin: 0 0 4px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.modal__sub {
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
.modal__close {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--o08);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.types {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 9px;
|
||||
padding: 18px 24px 4px;
|
||||
gap: 10px;
|
||||
padding: 20px 26px 4px;
|
||||
}
|
||||
|
||||
.type {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 13px;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--o10);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
background-color var(--t-fast),
|
||||
box-shadow var(--t-fast);
|
||||
}
|
||||
|
||||
.type:hover {
|
||||
background: var(--o04);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.type--active {
|
||||
background: var(--accent-bg-06);
|
||||
border-color: var(--accent-solid);
|
||||
background: var(--surface-2);
|
||||
border-color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.type__ico {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.type--active .type__ico {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.type__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type__title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.type__sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted2);
|
||||
line-height: 1.3;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 16px 24px 4px;
|
||||
padding: 18px 26px 8px;
|
||||
min-height: 172px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field-label:not(:first-child) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.text-input,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 120px;
|
||||
line-height: 1.6;
|
||||
color: var(--text3);
|
||||
min-height: 136px;
|
||||
line-height: 1.75;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.text-input:focus,
|
||||
.textarea:focus {
|
||||
border-color: var(--accent-border, var(--accent-ink));
|
||||
}
|
||||
|
||||
.scope {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -416,52 +458,75 @@ function submit() {
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 12px 13px;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
padding: 11px 13px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--o10);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
background-color var(--t-fast);
|
||||
}
|
||||
|
||||
.scope__opt:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.scope__opt--active {
|
||||
background: var(--accent-bg-06);
|
||||
border-color: var(--accent-solid);
|
||||
background: var(--surface-2);
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--o20);
|
||||
border: 1.5px solid var(--line-3);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.radio--on {
|
||||
border-color: var(--accent-solid);
|
||||
background: radial-gradient(circle, var(--accent-solid) 0 45%, transparent 48%);
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
.radio--on::after {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.scope__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scope__title {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.scope__sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
margin-top: 1px;
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.scope__hint {
|
||||
margin: 8px 2px 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
margin: 10px 2px 0;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.drop {
|
||||
@@ -471,16 +536,29 @@ function submit() {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 30px 20px;
|
||||
background: var(--bg);
|
||||
border: 1.5px dashed var(--o20);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
background: var(--surface-2);
|
||||
border: 1px dashed var(--line-3);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
background-color var(--t-fast);
|
||||
}
|
||||
|
||||
.drop:hover {
|
||||
border-color: var(--line-3);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.drop--over {
|
||||
border-color: var(--c-coral);
|
||||
background: var(--coral-bg);
|
||||
border-color: var(--celadon);
|
||||
background: var(--celadon-soft);
|
||||
}
|
||||
|
||||
.drop__ico {
|
||||
display: inline-flex;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hidden-file {
|
||||
@@ -488,37 +566,44 @@ function submit() {
|
||||
}
|
||||
|
||||
.drop__name {
|
||||
font-size: 14px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
color: var(--ink-2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.drop__hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted2);
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
border-radius: 11px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text3);
|
||||
line-height: 1.5;
|
||||
gap: 9px;
|
||||
margin-top: 14px;
|
||||
padding: 11px 13px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.note--teal {
|
||||
background: var(--teal-bg);
|
||||
border: 1px solid var(--teal-border);
|
||||
.note__ico {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.note--coral {
|
||||
background: var(--coral-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
.note--info {
|
||||
background: var(--indigo-soft);
|
||||
border: 1px solid var(--indigo-line);
|
||||
}
|
||||
|
||||
.note--warn {
|
||||
background: var(--brass-soft);
|
||||
border: 1px solid var(--brass-line);
|
||||
}
|
||||
|
||||
.foot {
|
||||
@@ -526,72 +611,66 @@ function submit() {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--o08);
|
||||
background: var(--surface);
|
||||
padding: 16px 26px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.foot__note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.foot__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.foot__dot--queue {
|
||||
background: var(--c-purple);
|
||||
background: var(--indigo);
|
||||
}
|
||||
|
||||
.foot__dot--instant {
|
||||
background: var(--c-teal);
|
||||
background: var(--celadon);
|
||||
}
|
||||
|
||||
.foot__actions {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
@media (max-width: 640px) {
|
||||
.scrim {
|
||||
padding: 20px 14px 40px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
.modal__head,
|
||||
.types,
|
||||
.body,
|
||||
.foot {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
.types {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background: var(--o05);
|
||||
.foot {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
.foot__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import {
|
||||
useKnowledgeExplorer,
|
||||
type ExplorerSource,
|
||||
type SourceStatus,
|
||||
type AddSourcePayload,
|
||||
} from '@/composables/useKnowledgeExplorer'
|
||||
import KnowledgeSourceTree from './KnowledgeSourceTree.vue'
|
||||
@@ -26,6 +27,7 @@ import KnowledgePageEditor from './KnowledgePageEditor.vue'
|
||||
import KnowledgePlanMeters from './KnowledgePlanMeters.vue'
|
||||
import KnowledgeAddSourceModal from './KnowledgeAddSourceModal.vue'
|
||||
import KnowledgeLinkModal from './KnowledgeLinkModal.vue'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -73,6 +75,42 @@ const largestSubpageCount = computed(() => {
|
||||
return s ? (s.pages ?? s.pageStubs).length : 0
|
||||
})
|
||||
|
||||
const statusText: Record<SourceStatus, string> = {
|
||||
queued: '排队中',
|
||||
crawling: '抓取中',
|
||||
synced: '已索引',
|
||||
error: '需要同步',
|
||||
}
|
||||
|
||||
const statusChip: Record<SourceStatus, string> = {
|
||||
queued: 'chip-quiet',
|
||||
crawling: 'chip-brass',
|
||||
synced: 'chip-ai',
|
||||
error: 'chip-wait',
|
||||
}
|
||||
|
||||
// The right-hand information column only exists on the full knowledge page.
|
||||
const hasInfoPane = computed(() => props.showPlanMeters)
|
||||
|
||||
const infoSource = computed(() => ex.selectedSource.value)
|
||||
const infoPage = computed(() => ex.selectedPage.value)
|
||||
const infoStatus = computed<SourceStatus | null>(() =>
|
||||
infoSource.value ? ex.sourceStatus(infoSource.value) : null,
|
||||
)
|
||||
const infoChip = computed(() => (infoStatus.value ? statusChip[infoStatus.value] : ''))
|
||||
const infoSourcePages = computed(() => {
|
||||
const source = infoSource.value
|
||||
return source ? (source.pages ?? source.pageStubs).length : 0
|
||||
})
|
||||
const infoSourceType = computed(() => {
|
||||
const t = (infoSource.value?.type || '').toLowerCase()
|
||||
if (t.includes('pdf') || t.includes('file')) return '文档'
|
||||
if (t.includes('sitemap')) return '站点地图'
|
||||
if (t.includes('web') || t.includes('site')) return '网页'
|
||||
if (t.includes('text') || t.includes('custom')) return '手动页面'
|
||||
return infoSource.value?.type || '来源'
|
||||
})
|
||||
|
||||
function openAdd() {
|
||||
addOpen.value = true
|
||||
}
|
||||
@@ -157,59 +195,80 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="explorer">
|
||||
<header class="explorer__header">
|
||||
<div class="explorer" :class="`explorer--${variant}`">
|
||||
<header
|
||||
class="explorer__head"
|
||||
:class="variant === 'page' ? 'page-head' : 'explorer__head--section'"
|
||||
>
|
||||
<div class="explorer__heading">
|
||||
<component :is="variant === 'page' ? 'h1' : 'h3'" class="explorer__title" :class="`explorer__title--${variant}`">
|
||||
<div v-if="variant === 'page'" class="eyebrow">Knowledge · 智能</div>
|
||||
<component
|
||||
:is="variant === 'page' ? 'h1' : 'h3'"
|
||||
:class="variant === 'page' ? 'title' : 'section-title'"
|
||||
>
|
||||
{{ title }}
|
||||
</component>
|
||||
<p v-if="description" class="explorer__desc">{{ description }}</p>
|
||||
<p
|
||||
v-if="description"
|
||||
:class="variant === 'page' ? 'lede' : 'explorer__section-desc'"
|
||||
>{{ description }}</p>
|
||||
</div>
|
||||
<div class="explorer__actions">
|
||||
|
||||
<div class="actions">
|
||||
<slot name="actions" />
|
||||
<button v-if="mode === 'agent'" class="btn btn--ghost" type="button" @click="ex.openLinkPicker">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" /></svg>
|
||||
<button
|
||||
v-if="mode === 'agent'"
|
||||
class="btn btn-ghost"
|
||||
type="button"
|
||||
@click="ex.openLinkPicker"
|
||||
>
|
||||
<span v-html="navIconSvg('link', 16, 'icon')"></span>
|
||||
关联已有
|
||||
</button>
|
||||
<button class="btn btn--primary" type="button" @click="openAdd">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
|
||||
<button class="btn btn-primary" type="button" @click="openAdd">
|
||||
<span v-html="navIconSvg('plus', 16, 'icon')"></span>
|
||||
添加来源
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<KnowledgePlanMeters
|
||||
v-if="showPlanMeters"
|
||||
class="explorer__meters"
|
||||
:source-count="ex.sources.value.length"
|
||||
:largest-source-name="largestSource?.name ?? null"
|
||||
:largest-subpage-count="largestSubpageCount"
|
||||
/>
|
||||
|
||||
<div v-if="ex.error.value" class="banner" role="alert">
|
||||
{{ ex.error.value }}
|
||||
<button class="banner__close" type="button" aria-label="关闭" @click="ex.error.value = null">×</button>
|
||||
<span class="banner__icon" v-html="navIconSvg('shield', 15, 'icon-sm')"></span>
|
||||
<span class="banner__text">{{ ex.error.value }}</span>
|
||||
<button
|
||||
class="banner__close"
|
||||
type="button"
|
||||
aria-label="关闭提示"
|
||||
@click="ex.error.value = null"
|
||||
>
|
||||
<span v-html="navIconSvg('x', 14, 'icon-xs')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="panel explorer__panel">
|
||||
<div class="explorer__grid" :class="{ 'explorer__grid--info': hasInfoPane }">
|
||||
<section class="pane pane--tree">
|
||||
<div class="pane-head">
|
||||
<span class="pane-title">来源</span>
|
||||
<span class="pane-count num">{{ ex.filteredSources.value.length }}</span>
|
||||
</div>
|
||||
<KnowledgeSourceTree
|
||||
class="grid__tree"
|
||||
:sources="ex.filteredSources.value"
|
||||
:selected-page-id="ex.selectedPageId.value"
|
||||
:selected-source-id="ex.selectedSourceId.value"
|
||||
:query="ex.query.value"
|
||||
:status-of="ex.sourceStatus"
|
||||
:page-rows-of="ex.pageRows"
|
||||
:loading="ex.isLoading.value"
|
||||
@update:query="ex.query.value = $event"
|
||||
@toggle="ex.toggleSource"
|
||||
@select="(source, pageId) => ex.selectPage(source, pageId)"
|
||||
@delete-source="askDeleteSource"
|
||||
@add-page="ex.startAddPage"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="grid__detail">
|
||||
<section class="pane pane--main">
|
||||
<KnowledgePageEditor
|
||||
v-if="ex.editing.value && (ex.selectedPage.value || ex.isAddingPage.value)"
|
||||
:title="ex.draftTitle.value"
|
||||
@@ -224,6 +283,7 @@ onUnmounted(() => {
|
||||
@save="ex.savePage"
|
||||
@cancel="ex.cancelEdit"
|
||||
/>
|
||||
|
||||
<KnowledgePageDetail
|
||||
v-else-if="ex.selectedPage.value && ex.selectedSource.value"
|
||||
:page="ex.selectedPage.value"
|
||||
@@ -235,13 +295,66 @@ onUnmounted(() => {
|
||||
@edit="ex.startEdit"
|
||||
@delete="askDeletePage"
|
||||
/>
|
||||
<div v-else class="empty">
|
||||
<div class="empty__orb"></div>
|
||||
<div class="empty__title">选择一个页面查看</div>
|
||||
<p class="empty__text">
|
||||
在左侧选择任一已提取的页面,即可查看内容、编辑或移除,也可以添加自己的来源。
|
||||
|
||||
<div v-else-if="ex.isLoading.value" class="state">
|
||||
<span class="state__mark" v-html="navIconSvg('clock', 20, 'icon')"></span>
|
||||
<p class="state__title">正在加载知识库</p>
|
||||
<p class="state__text">正在读取来源与页面,请稍候。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="state">
|
||||
<span class="state__mark" v-html="navIconSvg('knowledge', 20, 'icon')"></span>
|
||||
<p class="state__title">选择一个页面查看</p>
|
||||
<p class="state__text">
|
||||
在左侧展开来源、选择任一页面,即可查看内容、编辑或移除;也可以新建子页面。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside v-if="hasInfoPane" class="pane pane--info">
|
||||
<div class="pane-body pane-body--info">
|
||||
<section class="info-group">
|
||||
<h3 class="info-group__title">计划用量</h3>
|
||||
<KnowledgePlanMeters
|
||||
:source-count="ex.sources.value.length"
|
||||
:largest-source-name="largestSource?.name ?? null"
|
||||
:largest-subpage-count="largestSubpageCount"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="infoPage && infoSource" class="info-group">
|
||||
<h3 class="info-group__title">页面信息</h3>
|
||||
<dl class="info-list">
|
||||
<div class="info-row">
|
||||
<dt>来源</dt>
|
||||
<dd :title="infoSource.name">{{ infoSource.name }}</dd>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<dt>类型</dt>
|
||||
<dd>{{ infoSourceType }}</dd>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<dt>状态</dt>
|
||||
<dd>
|
||||
<span
|
||||
v-if="infoStatus"
|
||||
class="chip chip-sq"
|
||||
:class="infoChip"
|
||||
>
|
||||
<i class="d"></i>{{ statusText[infoStatus] }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<dt>该来源页数</dt>
|
||||
<dd class="num">{{ infoSourcePages }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<p v-else class="info-empty">选择页面后,这里会显示它的来源与状态。</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -250,8 +363,20 @@ onUnmounted(() => {
|
||||
<h3 class="confirm__title">{{ confirmState.title }}</h3>
|
||||
<p class="confirm__msg">{{ confirmState.message }}</p>
|
||||
<div class="confirm__actions">
|
||||
<button class="btn btn--ghost" type="button" :disabled="ex.isDeleting.value" @click="confirmState = null">取消</button>
|
||||
<button class="btn btn--danger-solid" type="button" :disabled="ex.isDeleting.value" @click="runConfirm">
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
type="button"
|
||||
:disabled="ex.isDeleting.value"
|
||||
@click="confirmState = null"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
type="button"
|
||||
:disabled="ex.isDeleting.value"
|
||||
@click="runConfirm"
|
||||
>
|
||||
{{ ex.isDeleting.value ? confirmState.busyLabel : confirmState.actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -286,215 +411,288 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.explorer__header {
|
||||
.explorer__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.explorer__head--section {
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.explorer__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.explorer__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.explorer__title--page {
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.explorer__title--section {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.explorer__desc {
|
||||
font-size: 14px;
|
||||
.explorer__section-desc {
|
||||
margin-top: 8px;
|
||||
max-width: 60ch;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.explorer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explorer__meters {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 47px;
|
||||
padding: 0 18px;
|
||||
border-radius: 11px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
|
||||
.btn--ghost {
|
||||
background: var(--o05);
|
||||
border-color: var(--o12);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) { background: var(--o08); }
|
||||
|
||||
.btn--danger-solid {
|
||||
background: var(--c-coral);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--danger-solid:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 14px;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
border-radius: 11px;
|
||||
color: var(--c-coral);
|
||||
font-size: 13.5px;
|
||||
border: 1px solid var(--vermilion-line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--vermilion-soft);
|
||||
color: var(--vermilion);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.banner__icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.banner__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.banner__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.grid {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: 344px 1fr;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.banner__close:hover {
|
||||
background: color-mix(in srgb, var(--vermilion) 12%, transparent);
|
||||
}
|
||||
|
||||
.grid__tree,
|
||||
.grid__detail {
|
||||
height: 640px;
|
||||
}
|
||||
|
||||
.grid__detail {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 16px;
|
||||
.explorer__panel {
|
||||
height: max(540px, calc(100vh - 318px));
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
.explorer--section .explorer__panel {
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.explorer__grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 292px) minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.explorer__grid--info {
|
||||
grid-template-columns: 292px minmax(0, 1fr) 258px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane + .pane {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.pane--tree {
|
||||
background: var(--surface-sunk);
|
||||
}
|
||||
|
||||
.pane--main {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.pane--info {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.pane-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 16px 16px 10px;
|
||||
}
|
||||
|
||||
.pane-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pane-count {
|
||||
font-family: var(--font-num);
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.pane-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pane-body--info {
|
||||
padding: 22px 18px 24px;
|
||||
}
|
||||
|
||||
.info-group + .info-group {
|
||||
margin-top: 22px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.info-group__title {
|
||||
margin-bottom: 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.info-row dt {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.info-row dd {
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-row dd.num {
|
||||
font-family: var(--font-num);
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.info-empty {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
padding: 40px 32px;
|
||||
}
|
||||
|
||||
.empty__orb {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin-bottom: 18px;
|
||||
.state__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, var(--c-lime), var(--c-purple), var(--c-teal), var(--c-coral), var(--c-lime));
|
||||
opacity: 0.85;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.empty__title {
|
||||
font-family: var(--font-display);
|
||||
.state__title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--text2);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.empty__text {
|
||||
font-size: 14px;
|
||||
.state__text {
|
||||
margin-top: 8px;
|
||||
max-width: 36ch;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
max-width: 320px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: var(--scrim);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.confirm__card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 26px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.confirm__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.confirm__msg {
|
||||
font-size: 14px;
|
||||
margin: 10px 0 22px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.confirm__actions {
|
||||
@@ -502,4 +700,118 @@ onUnmounted(() => {
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.explorer__grid--info {
|
||||
grid-template-columns: 264px minmax(0, 1fr) 236px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.explorer--page .explorer__panel {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.explorer--page .explorer__grid--info {
|
||||
grid-template-columns: 272px minmax(0, 1fr);
|
||||
grid-template-rows: minmax(520px, auto) auto;
|
||||
}
|
||||
|
||||
.explorer--page .pane--main {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.explorer--page .pane--info {
|
||||
grid-column: 1 / -1;
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.explorer--page .pane-body--info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
overflow: visible;
|
||||
padding: 22px 24px 26px;
|
||||
}
|
||||
|
||||
.explorer--page .info-group + .info-group {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.explorer__head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.explorer__head .actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.explorer--page .explorer__grid,
|
||||
.explorer--page .explorer__grid--info {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.explorer--page .pane--tree {
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.explorer--page .pane + .pane {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.explorer--page .pane--info {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.explorer__head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.explorer__grid,
|
||||
.explorer__grid--info {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.explorer--section .explorer__panel {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.pane + .pane {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.pane--tree {
|
||||
max-height: 340px;
|
||||
}
|
||||
|
||||
.pane--main {
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
.explorer__head--section {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.explorer__head .actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { KnowledgeItem } from '@/types/knowledge'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
const props = defineProps<{
|
||||
sources: KnowledgeItem[]
|
||||
@@ -40,6 +41,23 @@ const filteredSources = computed(() => {
|
||||
(s) => s.name.toLowerCase().includes(q) || s.type.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
function sourceIcon(item: KnowledgeItem): string {
|
||||
const t = (item.type || '').toLowerCase()
|
||||
if (t.includes('pdf') || t.includes('file')) return 'file'
|
||||
if (t.includes('sitemap')) return 'link'
|
||||
if (t.includes('web') || t.includes('site')) return 'globe'
|
||||
return 'folder'
|
||||
}
|
||||
|
||||
function typeLabel(item: KnowledgeItem): string {
|
||||
const t = (item.type || '').toLowerCase()
|
||||
if (t.includes('pdf') || t.includes('file')) return '文档'
|
||||
if (t.includes('sitemap')) return '站点地图'
|
||||
if (t.includes('web') || t.includes('site')) return '网页'
|
||||
if (t.includes('text') || t.includes('custom')) return '手动页面'
|
||||
return item.type || '来源'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -50,31 +68,47 @@ const filteredSources = computed(() => {
|
||||
<h3 class="modal__title">关联已有知识</h3>
|
||||
<p class="modal__sub">把组织中的知识关联到该智能体,供其使用。</p>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" aria-label="关闭" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||
<button class="icon-btn sm modal__close" type="button" aria-label="关闭" @click="emit('close')">
|
||||
<span v-html="navIconSvg('x', 16, 'icon-sm')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<input v-model="query" class="text-input" type="text" placeholder="搜索知识来源…" />
|
||||
<div class="search-field">
|
||||
<div class="search-field__inner">
|
||||
<span class="search-field__ico" v-html="navIconSvg('search', 15, 'icon-sm')"></span>
|
||||
<input
|
||||
v-model="query"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="搜索知识来源…"
|
||||
aria-label="搜索知识来源"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div v-if="loading" class="state">正在加载知识来源…</div>
|
||||
<div v-else-if="error" class="state state--error">{{ error }}</div>
|
||||
<div v-if="loading" class="state">
|
||||
<span class="state__mark" v-html="navIconSvg('clock', 20, 'icon')"></span>
|
||||
<p>正在加载知识来源…</p>
|
||||
</div>
|
||||
<div v-else-if="error" class="state state--error">
|
||||
<span class="state__mark" v-html="navIconSvg('shield', 20, 'icon')"></span>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
<div v-else-if="!filteredSources.length" class="state">
|
||||
{{ query ? '没有匹配的来源。' : '组织中还没有可关联的知识。' }}
|
||||
<span class="state__mark" v-html="navIconSvg('folder', 20, 'icon')"></span>
|
||||
<p>{{ query ? '没有匹配的来源。' : '组织中还没有可关联的知识。' }}</p>
|
||||
</div>
|
||||
<ul v-else class="list">
|
||||
<li v-for="item in filteredSources" :key="item.id" class="row">
|
||||
<span class="row__mark" v-html="navIconSvg(sourceIcon(item), 16, 'icon')"></span>
|
||||
<div class="row__main">
|
||||
<span class="row__name">{{ item.name }}</span>
|
||||
<span class="row__type">{{ item.type }}</span>
|
||||
<span class="row__type">{{ typeLabel(item) }} · {{ item.pages?.length ?? 0 }} 页</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="linkedIds.has(item.id)"
|
||||
class="btn btn--linked"
|
||||
class="btn btn-ghost btn-sm row__action"
|
||||
type="button"
|
||||
:disabled="busyIds.has(item.id)"
|
||||
@click="emit('unlink', item.id)"
|
||||
@@ -83,7 +117,7 @@ const filteredSources = computed(() => {
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn--primary"
|
||||
class="btn btn-primary btn-sm row__action"
|
||||
type="button"
|
||||
:disabled="busyIds.has(item.id)"
|
||||
@click="emit('link', item.id)"
|
||||
@@ -95,7 +129,7 @@ const filteredSources = computed(() => {
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<button class="btn btn--ghost" type="button" @click="emit('close')">完成</button>
|
||||
<button class="btn btn-ghost" type="button" @click="emit('close')">完成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,131 +139,158 @@ const filteredSources = computed(() => {
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--scrim);
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
padding: 48px 20px;
|
||||
overflow-y: auto;
|
||||
background: var(--scrim);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 560px;
|
||||
max-width: 100%;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--o08);
|
||||
padding: 22px 26px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: var(--text);
|
||||
margin: 0 0 4px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.modal__sub {
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
.modal__close {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--o08);
|
||||
color: var(--text2);
|
||||
.search-field {
|
||||
padding: 18px 26px 4px;
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 16px 24px 4px;
|
||||
.search-field__inner {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
.search-field__ico {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
color: var(--faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--accent-border, var(--accent-ink));
|
||||
.search-field .form-input {
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 12px 24px 4px;
|
||||
min-height: 180px;
|
||||
max-height: 340px;
|
||||
padding: 14px 26px 6px;
|
||||
min-height: 200px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
gap: 10px;
|
||||
min-height: 180px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.state--error {
|
||||
color: var(--c-coral);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.state--error .state__mark {
|
||||
background: var(--vermilion-soft);
|
||||
border-color: var(--vermilion-line);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid var(--o10);
|
||||
background: var(--o04);
|
||||
gap: 11px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
background-color var(--t-fast);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
|
||||
.row__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -237,72 +298,48 @@ const filteredSources = computed(() => {
|
||||
}
|
||||
|
||||
.row__name {
|
||||
font-size: 13.5px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
word-break: break-word;
|
||||
color: var(--ink-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row__type {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted2);
|
||||
text-transform: capitalize;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row__action {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.row__action.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--vermilion-soft);
|
||||
border-color: var(--vermilion-line);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 9px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--o08);
|
||||
background: var(--surface);
|
||||
padding: 16px 26px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
@media (max-width: 640px) {
|
||||
.scrim {
|
||||
padding: 20px 14px 40px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
.modal__head,
|
||||
.search-field,
|
||||
.body,
|
||||
.foot {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn--linked {
|
||||
background: var(--o05);
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--linked:hover:not(:disabled) {
|
||||
background: var(--coral-bg);
|
||||
border-color: var(--coral-border);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background: var(--o05);
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
import { computed } from 'vue'
|
||||
import type { KnowledgeLinkedAgent, KnowledgeSubPage } from '@/types/knowledge'
|
||||
import type { SourceStatus } from '@/composables/useKnowledgeExplorer'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
const props = defineProps<{
|
||||
page: KnowledgeSubPage
|
||||
@@ -27,21 +28,25 @@ const props = defineProps<{
|
||||
deleting: boolean
|
||||
}>()
|
||||
|
||||
// First-letter avatar for an agent chip.
|
||||
const initial = (name: string) => (name.trim()[0] || '?').toUpperCase()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'edit'): void
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
const statusText: Record<SourceStatus, string> = {
|
||||
queued: '等待中',
|
||||
queued: '排队中',
|
||||
crawling: '抓取中',
|
||||
synced: '已同步',
|
||||
synced: '已索引',
|
||||
error: '需要同步',
|
||||
}
|
||||
|
||||
const statusChip: Record<SourceStatus, string> = {
|
||||
queued: 'chip-quiet',
|
||||
crawling: 'chip-brass',
|
||||
synced: 'chip-ai',
|
||||
error: 'chip-wait',
|
||||
}
|
||||
|
||||
const paragraphs = computed(() =>
|
||||
(props.page.content || '')
|
||||
.split(/\n{2,}/)
|
||||
@@ -51,81 +56,119 @@ const paragraphs = computed(() =>
|
||||
|
||||
const href = computed(() => {
|
||||
const url = props.page.url || ''
|
||||
if (!url) return ''
|
||||
return /^https?:/i.test(url) ? url : `https://${url}`
|
||||
})
|
||||
|
||||
const updatedLabel = computed(() => {
|
||||
if (!props.page.updated_at) return '未知'
|
||||
const d = new Date(props.page.updated_at)
|
||||
return Number.isNaN(d.getTime()) ? '未知' : d.toLocaleDateString()
|
||||
function timeAgo(iso: string | null): string {
|
||||
if (!iso) return '未知'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return '未知'
|
||||
const diff = Date.now() - date.getTime()
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (diff < 0) return date.toLocaleDateString()
|
||||
if (diff < hour) return `${Math.max(1, Math.floor(diff / minute))} 分钟前`
|
||||
if (diff < day) return `${Math.floor(diff / hour)} 小时前`
|
||||
if (diff < 2 * day) return '昨天'
|
||||
if (diff < 30 * day) return `${Math.floor(diff / day)} 天前`
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dayOfMonth = String(date.getDate()).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${month}-${dayOfMonth}`
|
||||
}
|
||||
|
||||
const updatedLabel = computed(() => timeAgo(props.page.updated_at))
|
||||
|
||||
const wordLabel = computed(() => (props.page.word_count || 0).toLocaleString())
|
||||
|
||||
// First-letter avatar for an agent chip.
|
||||
const initial = (name: string) => (name.trim()[0] || '?').toUpperCase()
|
||||
|
||||
const sourceTypeLabel = computed(() => {
|
||||
const t = (props.sourceType || '').toLowerCase()
|
||||
if (t.includes('pdf') || t.includes('file')) return '文档'
|
||||
if (t.includes('sitemap')) return '站点地图'
|
||||
if (t.includes('web') || t.includes('site')) return '网页'
|
||||
if (t.includes('text') || t.includes('custom')) return '手动页面'
|
||||
return props.sourceType || '来源'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail">
|
||||
<div class="detail__head">
|
||||
<div class="crumbs">
|
||||
<span class="crumbs__src" :title="sourceName">{{ sourceName }}</span>
|
||||
<span class="crumbs__sep">/</span>
|
||||
<span class="crumbs__type">{{ sourceType }}</span>
|
||||
<div class="detail__title-wrap">
|
||||
<div class="detail__kicker">
|
||||
<span>来源</span>
|
||||
<span class="detail__kicker-name" :title="sourceName">{{ sourceName }}</span>
|
||||
<span class="detail__kicker-sep">·</span>
|
||||
<span>{{ sourceTypeLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail__title-row">
|
||||
<div class="detail__title-wrap">
|
||||
<h2 class="detail__title">{{ page.title }}</h2>
|
||||
<a class="detail__url" :href="href" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<path d="M15 3h6v6" /><path d="M10 14 21 3" />
|
||||
</svg>
|
||||
|
||||
<a
|
||||
v-if="page.url"
|
||||
class="detail__url"
|
||||
:href="href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="page.url"
|
||||
>
|
||||
<span v-html="navIconSvg('link', 13, 'icon-xs')"></span>
|
||||
<span class="detail__url-text">{{ page.url }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="detail__actions">
|
||||
<button class="btn btn--ghost" type="button" @click="$emit('edit')">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" />
|
||||
</svg>
|
||||
编辑
|
||||
</button>
|
||||
<button class="btn btn--danger" type="button" :disabled="deleting" title="删除页面"
|
||||
@click="$emit('delete')">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
<path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<span class="meta__item">{{ page.word_count }} 词</span>
|
||||
<span class="meta__sep"></span>
|
||||
<span class="meta__item">更新于 {{ updatedLabel }}</span>
|
||||
<span class="meta__sep"></span>
|
||||
<span class="pill" :class="`pill--${status}`">
|
||||
<span class="pill__dot"></span>{{ statusText[status] }}
|
||||
<div class="preview-meta">
|
||||
<span class="chip chip-sq" :class="statusChip[status]">
|
||||
<i class="d"></i>{{ statusText[status] }}
|
||||
</span>
|
||||
<i class="preview-meta__sep" aria-hidden="true"></i>
|
||||
<span><span class="num">{{ wordLabel }}</span> 词</span>
|
||||
<i class="preview-meta__sep" aria-hidden="true"></i>
|
||||
<span>{{ page.chunk_count }} 个片段</span>
|
||||
<i class="preview-meta__sep" aria-hidden="true"></i>
|
||||
<span>更新于 {{ updatedLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="agents && agents.length" class="usedby">
|
||||
<span class="usedby__label">使用方</span>
|
||||
<span v-for="agent in agents" :key="agent.id" class="agent-chip" :title="agent.name">
|
||||
<span class="agent-chip__avatar">{{ initial(agent.name) }}</span>
|
||||
<span class="agent-chip__name">{{ agent.name }}</span>
|
||||
<span v-for="agent in agents" :key="agent.id" class="agent-pill" :title="agent.name">
|
||||
<span class="agent-pill__avatar">{{ initial(agent.name) }}</span>
|
||||
<span class="agent-pill__name">{{ agent.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc">
|
||||
<div class="detail__actions">
|
||||
<button class="btn btn-ghost btn-sm" type="button" @click="$emit('edit')">
|
||||
<span v-html="navIconSvg('edit', 14, 'icon-sm')"></span>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn sm detail__del"
|
||||
type="button"
|
||||
:disabled="deleting"
|
||||
title="删除页面"
|
||||
aria-label="删除页面"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
<span v-html="navIconSvg('trash', 15, 'icon-sm')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="doc">
|
||||
<template v-if="paragraphs.length">
|
||||
<p v-for="(para, i) in paragraphs" :key="i">{{ para }}</p>
|
||||
<div v-if="paragraphs.length === 0" class="doc__empty">
|
||||
该页面还没有内容,点击 <strong>编辑</strong> 添加内容。
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="doc__empty">
|
||||
<span class="doc__empty-mark" v-html="navIconSvg('file', 20, 'icon')"></span>
|
||||
<p class="doc__empty-title">该页面还没有内容</p>
|
||||
<p class="doc__empty-text">点击「编辑」添加内容;保存后会重新生成向量。</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -138,37 +181,11 @@ const updatedLabel = computed(() => {
|
||||
}
|
||||
|
||||
.detail__head {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumbs__src {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.crumbs__sep { color: var(--faint); }
|
||||
.crumbs__type { color: var(--text3); }
|
||||
|
||||
.detail__title-row {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
gap: 18px;
|
||||
padding: 24px 28px 20px;
|
||||
}
|
||||
|
||||
.detail__title-wrap {
|
||||
@@ -176,13 +193,42 @@ const updatedLabel = computed(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail__kicker {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
margin-bottom: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail__kicker-name {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 26ch;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.detail__kicker-sep {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.detail__title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.35;
|
||||
color: var(--ink);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -190,188 +236,191 @@ const updatedLabel = computed(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--c-teal);
|
||||
text-decoration: none;
|
||||
max-width: 100%;
|
||||
margin-top: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--celadon);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.detail__url:hover .detail__url-text {
|
||||
border-bottom-color: var(--celadon-line);
|
||||
}
|
||||
|
||||
.detail__url-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.detail__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--o12);
|
||||
background: var(--o05);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--o08); }
|
||||
|
||||
.btn--danger {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
height: 34px;
|
||||
.detail__del {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
border-color: var(--coral-border);
|
||||
.detail__del:hover:not(:disabled) {
|
||||
background: var(--vermilion-soft);
|
||||
border-color: var(--vermilion-line);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
.detail__del:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
.preview-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted2);
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.meta__sep {
|
||||
.preview-meta__sep {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--teal-border);
|
||||
background: var(--teal-bg);
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.pill__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
.preview-meta .num {
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.usedby {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.usedby__label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted2);
|
||||
color: var(--faint);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.agent-chip {
|
||||
.agent-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px 3px 3px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--o12);
|
||||
background: var(--o05);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
max-width: 180px;
|
||||
gap: 5px;
|
||||
max-width: 170px;
|
||||
padding: 2px 8px 2px 2px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-2);
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.agent-chip__avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
.agent-pill__avatar {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--teal-bg);
|
||||
color: var(--c-teal);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
background: var(--celadon-soft);
|
||||
color: var(--celadon);
|
||||
border: 1px solid var(--celadon-line);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-chip__name {
|
||||
.agent-pill__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill--crawling {
|
||||
border-color: var(--warning-border, var(--o12));
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.pill--queued {
|
||||
border-color: var(--purple-border, var(--o12));
|
||||
background: var(--purple-bg, var(--o06));
|
||||
color: var(--c-purple);
|
||||
}
|
||||
|
||||
.pill--error {
|
||||
border-color: var(--coral-border);
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.doc {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px;
|
||||
color: var(--text3);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
font-family: var(--font-sans);
|
||||
padding: 24px 28px 36px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
line-height: 1.95;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.doc p {
|
||||
margin: 0 0 14px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.doc p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.doc__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--muted2);
|
||||
font-size: 14px;
|
||||
padding: 40px 0;
|
||||
padding: 48px 20px;
|
||||
}
|
||||
|
||||
.doc__empty-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.doc__empty-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.doc__empty-text {
|
||||
margin-top: 6px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detail__head {
|
||||
flex-direction: column;
|
||||
padding: 20px 18px 16px;
|
||||
}
|
||||
|
||||
.detail__actions {
|
||||
align-self: stretch;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.detail__title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.doc {
|
||||
padding: 20px 18px 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,7 @@ limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -42,45 +43,52 @@ const canSave = computed(() => props.content.trim().length > 0 && !props.saving)
|
||||
<template>
|
||||
<div class="editor">
|
||||
<div class="editor__body">
|
||||
<div class="editor__field">
|
||||
<label class="editor__label" for="kb-page-title">页面标题</label>
|
||||
<input
|
||||
id="kb-page-title"
|
||||
class="editor__title"
|
||||
class="form-input editor__title"
|
||||
:value="title"
|
||||
type="text"
|
||||
placeholder="页面标题"
|
||||
@input="emit('update:title', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="showUrl">
|
||||
<div v-if="showUrl" class="editor__field">
|
||||
<label class="editor__label" for="kb-page-url">来源 URL(可选)</label>
|
||||
<input
|
||||
id="kb-page-url"
|
||||
class="editor__title editor__url"
|
||||
class="form-input editor__url"
|
||||
:value="url"
|
||||
type="url"
|
||||
placeholder="https://example.com/page"
|
||||
@input="emit('update:url', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="editor__field editor__field--grow">
|
||||
<label class="editor__label" for="kb-page-content">页面内容</label>
|
||||
<textarea
|
||||
id="kb-page-content"
|
||||
class="editor__content"
|
||||
class="form-input editor__content"
|
||||
:value="content"
|
||||
placeholder="在此输入页面内容,段落之间留一个空行。"
|
||||
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor__foot">
|
||||
<span class="editor__status">
|
||||
<span class="editor__status-dot"></span>编辑中——保存后重新生成向量
|
||||
</span>
|
||||
<div class="editor__buttons">
|
||||
<button class="btn btn--ghost" type="button" :disabled="saving" @click="emit('cancel')">取消</button>
|
||||
<button class="btn btn--primary" type="button" :disabled="!canSave" @click="emit('save')">
|
||||
<button class="btn btn-ghost" type="button" :disabled="saving" @click="emit('cancel')">
|
||||
取消
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" :disabled="!canSave" @click="emit('save')">
|
||||
<span v-if="!saving" v-html="navIconSvg('check', 15, 'icon-sm')"></span>
|
||||
{{ saving ? '保存中…' : submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -98,119 +106,107 @@ const canSave = computed(() => props.content.trim().length > 0 && !props.saving)
|
||||
|
||||
.editor__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 18px 24px;
|
||||
padding: 24px 28px 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.editor__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor__field--grow {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor__label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted2);
|
||||
margin-bottom: 8px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.editor__title {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 9px;
|
||||
outline: none;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.4;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.editor__url {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.editor__title:focus,
|
||||
.editor__content:focus {
|
||||
border-color: var(--accent-border, var(--accent-ink));
|
||||
padding: 11px 14px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.editor__content {
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--text3);
|
||||
outline: none;
|
||||
min-height: 280px;
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.85;
|
||||
color: var(--ink-2);
|
||||
resize: vertical;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.editor__foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--o10);
|
||||
background: var(--bg2);
|
||||
flex-shrink: 0;
|
||||
gap: 14px;
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.editor__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor__status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: var(--c-coral);
|
||||
background: var(--brass);
|
||||
}
|
||||
|
||||
.editor__buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 18px;
|
||||
border-radius: 9px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
@media (max-width: 720px) {
|
||||
.editor__body {
|
||||
padding: 20px 18px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
.editor__foot {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o12);
|
||||
color: var(--muted);
|
||||
.editor__buttons {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn--ghost:hover:not(:disabled) { background: var(--o05); }
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
</style>
|
||||
@@ -44,7 +44,9 @@ function pct(used: number, limit: number | null): number {
|
||||
return Math.max(6, Math.min(100, Math.round((used / limit) * 100)))
|
||||
}
|
||||
|
||||
const sourcesOver = computed(() => maxSources.value !== null && props.sourceCount > maxSources.value)
|
||||
const sourcesOver = computed(
|
||||
() => maxSources.value !== null && props.sourceCount > maxSources.value,
|
||||
)
|
||||
const subpagesOver = computed(
|
||||
() => maxSubpages.value !== null && props.largestSubpageCount > maxSubpages.value,
|
||||
)
|
||||
@@ -55,60 +57,52 @@ const subpagesOver = computed(
|
||||
<div class="meter" :class="{ 'meter--over': sourcesOver }">
|
||||
<div class="meter__head">
|
||||
<span class="meter__label">知识来源</span>
|
||||
<span class="meter__value">
|
||||
{{ sourceCount }}<template v-if="maxSources !== null"> / {{ maxSources }}</template>
|
||||
<span class="meter__value num">
|
||||
{{ sourceCount }}<small v-if="maxSources !== null">/{{ maxSources }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="maxSources !== null" class="meter__track">
|
||||
<div class="meter__fill" :style="{ width: pct(sourceCount, maxSources) + '%' }"></div>
|
||||
<span class="meter__fill" :style="{ width: pct(sourceCount, maxSources) + '%' }"></span>
|
||||
</div>
|
||||
<div class="meter__note">
|
||||
<template v-if="sourcesOver">已超出套餐的来源数量上限,升级后可关联更多来源。</template>
|
||||
<p class="meter__note">
|
||||
<template v-if="sourcesOver">已超出套餐上限,升级后可关联更多来源。</template>
|
||||
<template v-else-if="maxSources !== null">已关联 {{ sourceCount }} / {{ maxSources }} 个来源。</template>
|
||||
<template v-else>已关联 {{ sourceCount }} 个来源。</template>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="meter" :class="{ 'meter--over': subpagesOver }">
|
||||
<div class="meter__head">
|
||||
<span class="meter__label">每个来源的子页面</span>
|
||||
<span class="meter__value">
|
||||
<template v-if="maxSubpages !== null">最多 {{ maxSubpages }}</template>
|
||||
<template v-else>—</template>
|
||||
<span class="meter__label">单个来源的子页面</span>
|
||||
<span class="meter__value num">
|
||||
{{ largestSubpageCount }}<small v-if="maxSubpages !== null">/{{ maxSubpages }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="maxSubpages !== null" class="meter__track">
|
||||
<div class="meter__fill meter__fill--alt"
|
||||
:style="{ width: pct(largestSubpageCount, maxSubpages) + '%' }"></div>
|
||||
<span
|
||||
class="meter__fill meter__fill--alt"
|
||||
:style="{ width: pct(largestSubpageCount, maxSubpages) + '%' }"
|
||||
></span>
|
||||
</div>
|
||||
<div class="meter__note">
|
||||
<p class="meter__note">
|
||||
<template v-if="largestSourceName">
|
||||
最大来源:{{ largestSourceName }} — {{ largestSubpageCount }}<template v-if="maxSubpages !== null"> / {{ maxSubpages }}</template> 个子页面
|
||||
最大来源:{{ largestSourceName }}<template v-if="maxSubpages !== null">(上限 {{ maxSubpages }})</template>
|
||||
</template>
|
||||
<template v-else>还没有来源。</template>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.meters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.meters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.meter {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meter__head {
|
||||
@@ -116,56 +110,70 @@ const subpagesOver = computed(
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.meter__label {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.meter__value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
font-family: var(--font-num);
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meter--over .meter__value {
|
||||
color: var(--c-coral);
|
||||
.meter__value small {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.meter__track {
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--o07);
|
||||
height: 3px;
|
||||
margin-top: 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meter__fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--c-teal);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: inherit;
|
||||
background: var(--ink);
|
||||
transition: width var(--t-med);
|
||||
}
|
||||
|
||||
.meter__fill--alt {
|
||||
background: var(--c-purple);
|
||||
}
|
||||
|
||||
.meter--over .meter__fill {
|
||||
background: var(--c-coral);
|
||||
background: var(--celadon);
|
||||
}
|
||||
|
||||
.meter__note {
|
||||
font-size: 12.5px;
|
||||
margin-top: 9px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
margin-top: 10px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.meter--over .meter__value,
|
||||
.meter--over .meter__note {
|
||||
color: var(--c-coral);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.meter--over .meter__fill {
|
||||
background: var(--vermilion);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.meter__fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ExplorerSource, SourceStatus } from '@/composables/useKnowledgeExplorer'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
|
||||
interface PageRow {
|
||||
page_id: string
|
||||
@@ -23,21 +24,18 @@ interface PageRow {
|
||||
words: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sources: ExplorerSource[]
|
||||
selectedPageId: string | null
|
||||
selectedSourceId: number | null
|
||||
query: string
|
||||
statusOf: (source: ExplorerSource) => SourceStatus
|
||||
pageRowsOf: (source: ExplorerSource) => PageRow[]
|
||||
}>()
|
||||
|
||||
// Group page rows once per render (the template reads them twice per source).
|
||||
const rowsBySource = computed(() => {
|
||||
const map = new Map<number, PageRow[]>()
|
||||
for (const source of props.sources) map.set(source.id, props.pageRowsOf(source))
|
||||
return map
|
||||
})
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{ loading: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:query', value: string): void
|
||||
@@ -48,9 +46,9 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const statusLabel: Record<SourceStatus, string> = {
|
||||
queued: '等待中',
|
||||
queued: '排队中',
|
||||
crawling: '抓取中',
|
||||
synced: '已同步',
|
||||
synced: '已索引',
|
||||
error: '需要同步',
|
||||
}
|
||||
|
||||
@@ -58,17 +56,58 @@ const statusLabel: Record<SourceStatus, string> = {
|
||||
// merely "needs sync".
|
||||
function queuedLabel(source: ExplorerSource): string {
|
||||
const status = props.statusOf(source)
|
||||
return status === 'error' ? '失败' : statusLabel[status]
|
||||
return status === 'error' ? '抓取失败' : statusLabel[status]
|
||||
}
|
||||
|
||||
function sourceGlyph(type: string): string {
|
||||
const t = (type || '').toLowerCase()
|
||||
if (t.includes('pdf') || t === 'file') return 'P'
|
||||
if (t.includes('site')) return 'S'
|
||||
if (t.includes('web')) return 'W'
|
||||
return 'T'
|
||||
// Group page rows once per render (the template reads them twice per source).
|
||||
const rowsBySource = computed(() => {
|
||||
const map = new Map<number, PageRow[]>()
|
||||
for (const source of props.sources) map.set(source.id, props.pageRowsOf(source))
|
||||
return map
|
||||
})
|
||||
|
||||
const rowsOf = (source: ExplorerSource) => rowsBySource.value.get(source.id) ?? []
|
||||
|
||||
const groups = computed(() => {
|
||||
const queued = props.sources.filter((s) => s.queued)
|
||||
const indexed = props.sources.filter((s) => !s.queued)
|
||||
return [
|
||||
{ key: 'queued', label: '抓取队列', sources: queued },
|
||||
{ key: 'indexed', label: '已入库', sources: indexed },
|
||||
].filter((group) => group.sources.length > 0)
|
||||
})
|
||||
|
||||
function sourceIcon(source: ExplorerSource): string {
|
||||
const t = (source.type || '').toLowerCase()
|
||||
if (t.includes('pdf') || t.includes('file')) return 'file'
|
||||
if (t.includes('sitemap')) return 'link'
|
||||
if (t.includes('web') || t.includes('site')) return 'globe'
|
||||
if (t.includes('text') || t.includes('custom')) return 'file'
|
||||
return 'folder'
|
||||
}
|
||||
|
||||
function sourceTypeLabel(source: ExplorerSource): string {
|
||||
const t = (source.type || '').toLowerCase()
|
||||
if (t.includes('pdf') || t.includes('file')) return '文档'
|
||||
if (t.includes('sitemap')) return '站点地图'
|
||||
if (t.includes('web') || t.includes('site')) return '网页'
|
||||
if (t.includes('text') || t.includes('custom')) return '手动页面'
|
||||
return source.type || '来源'
|
||||
}
|
||||
|
||||
function countLabel(source: ExplorerSource): string {
|
||||
if (source.queued) return queuedLabel(source)
|
||||
return `${(source.pages ?? source.pageStubs).length} 页`
|
||||
}
|
||||
|
||||
function statusTitle(source: ExplorerSource): string {
|
||||
return source.queued ? queuedLabel(source) : statusLabel[props.statusOf(source)]
|
||||
}
|
||||
|
||||
const isSourceCurrent = (source: ExplorerSource) => source.id === props.selectedSourceId
|
||||
const isPageCurrent = (source: ExplorerSource, pageId: string) =>
|
||||
source.id === props.selectedSourceId && pageId === props.selectedPageId
|
||||
|
||||
// First-letter avatar for a linked agent.
|
||||
const agentInitial = (name: string) => (name.trim()[0] || '?').toUpperCase()
|
||||
|
||||
@@ -78,24 +117,32 @@ const MAX_AVATARS = 3
|
||||
|
||||
<template>
|
||||
<div class="tree">
|
||||
<div class="tree__search">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<label class="tree__search">
|
||||
<span class="tree__search-ic" v-html="navIconSvg('search', 15, 'icon-sm')"></span>
|
||||
<input
|
||||
:value="query"
|
||||
type="search"
|
||||
placeholder="搜索页面…"
|
||||
aria-label="搜索页面"
|
||||
aria-label="搜索来源或页面"
|
||||
@input="emit('update:query', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="tree__body">
|
||||
<div v-for="source in sources" :key="source.id" class="src">
|
||||
<div class="src__row">
|
||||
<div v-if="loading && !sources.length" class="tree__loading">
|
||||
<span class="sr-only" role="status">正在加载知识来源…</span>
|
||||
<span v-for="n in 5" :key="n" class="tree__skeleton" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section v-for="group in groups" :key="group.key" class="group">
|
||||
<div class="group__head">
|
||||
<span class="group__title">{{ group.label }}</span>
|
||||
<span class="group__count num">{{ group.sources.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-for="source in group.sources" :key="source.id" class="src">
|
||||
<div class="src__row" :aria-current="isSourceCurrent(source) ? 'true' : undefined">
|
||||
<button
|
||||
class="src__toggle"
|
||||
type="button"
|
||||
@@ -103,20 +150,33 @@ const MAX_AVATARS = 3
|
||||
:disabled="source.queued"
|
||||
@click="!source.queued && emit('toggle', source)"
|
||||
>
|
||||
<svg v-if="!source.queued" class="chev" :class="{ 'chev--open': source.expanded }" viewBox="0 0 24 24"
|
||||
width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
<span v-else class="chev-spacer"></span>
|
||||
<span class="src__glyph" :class="`src__glyph--${sourceGlyph(source.type).toLowerCase()}`">{{ sourceGlyph(source.type) }}</span>
|
||||
<span
|
||||
v-if="!source.queued"
|
||||
class="src__chev"
|
||||
:class="{ 'src__chev--open': source.expanded }"
|
||||
v-html="navIconSvg('chevronRight', 14, 'icon-xs')"
|
||||
></span>
|
||||
<span v-else class="src__chev-spacer"></span>
|
||||
|
||||
<span
|
||||
class="src__mark"
|
||||
:class="{ 'src__mark--busy': source.queued }"
|
||||
v-html="navIconSvg(sourceIcon(source), 16, 'icon')"
|
||||
></span>
|
||||
|
||||
<span class="src__meta">
|
||||
<span class="src__name" :title="source.name">{{ source.name }}</span>
|
||||
<span class="src__count" :class="{ 'src__count--error': source.queued && statusOf(source) === 'error' }">
|
||||
{{ source.queued ? queuedLabel(source) : (source.pages ?? source.pageStubs).length + ' 个子页面' }}
|
||||
<span class="src__sub">
|
||||
<span>{{ sourceTypeLabel(source) }}</span>
|
||||
<span class="src__sub-sep" aria-hidden="true">·</span>
|
||||
<span
|
||||
class="src__count"
|
||||
:class="{ 'src__count--error': source.queued && statusOf(source) === 'error' }"
|
||||
>{{ countLabel(source) }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<span
|
||||
v-if="!source.queued && source.agents.length"
|
||||
class="agents"
|
||||
@@ -131,28 +191,31 @@ const MAX_AVATARS = 3
|
||||
+{{ source.agents.length - MAX_AVATARS }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="dot" :class="`dot--${statusOf(source)}`" :title="statusLabel[statusOf(source)]"></span>
|
||||
|
||||
<span
|
||||
class="src__status"
|
||||
:class="`src__status--${statusOf(source)}`"
|
||||
:title="statusTitle(source)"
|
||||
></span>
|
||||
|
||||
<button
|
||||
v-if="!source.queued"
|
||||
class="src__add"
|
||||
class="src__act"
|
||||
type="button"
|
||||
title="为该来源添加子页面"
|
||||
aria-label="为该来源添加子页面"
|
||||
@click="emit('add-page', source)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
|
||||
<span v-html="navIconSvg('plus', 14, 'icon-xs')"></span>
|
||||
</button>
|
||||
<button
|
||||
class="src__del"
|
||||
class="src__act src__act--danger"
|
||||
type="button"
|
||||
:title="!source.queued ? '删除此来源' : (statusOf(source) === 'error' ? '忽略失败的来源' : '取消抓取')"
|
||||
:title="!source.queued ? '删除此来源' : statusOf(source) === 'error' ? '忽略失败的来源' : '取消抓取'"
|
||||
:aria-label="!source.queued ? '删除此来源' : statusOf(source) === 'error' ? '忽略失败的来源' : '取消抓取'"
|
||||
@click="emit('delete-source', source)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
<path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
|
||||
</svg>
|
||||
<span v-html="navIconSvg('trash', 14, 'icon-xs')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -161,95 +224,163 @@ const MAX_AVATARS = 3
|
||||
</div>
|
||||
|
||||
<div v-if="source.expanded && !source.queued" class="src__pages">
|
||||
<div v-if="source.loadingContent" class="src__hint">正在加载页面…</div>
|
||||
<div v-else-if="source.contentError" class="src__hint src__hint--error">{{ source.contentError }}</div>
|
||||
<div class="src__pages-head">
|
||||
<span class="src__pages-title">页面</span>
|
||||
<span class="src__pages-count num">{{ rowsOf(source).length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="source.loadingContent" class="hint">正在加载页面…</div>
|
||||
<div v-else-if="source.contentError" class="hint hint--error">{{ source.contentError }}</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="row in (rowsBySource.get(source.id) || [])"
|
||||
v-for="row in rowsOf(source)"
|
||||
:key="row.page_id"
|
||||
type="button"
|
||||
class="pg"
|
||||
:class="{ 'pg--active': source.id === selectedSourceId && row.page_id === selectedPageId }"
|
||||
:aria-current="isPageCurrent(source, row.page_id) ? 'true' : undefined"
|
||||
@click="emit('select', source, row.page_id)"
|
||||
>
|
||||
<span class="pg__dot"></span>
|
||||
<span class="pg__mark" v-html="navIconSvg('file', 13, 'icon-xs')"></span>
|
||||
<span class="pg__title" :title="row.title">{{ row.title }}</span>
|
||||
<span v-if="row.words !== null" class="pg__words">{{ row.words }} 词</span>
|
||||
<span v-if="row.words !== null" class="pg__words num">{{ row.words }}</span>
|
||||
</button>
|
||||
<div v-if="(rowsBySource.get(source.id) || []).length === 0" class="src__hint">尚未提取任何页面。</div>
|
||||
|
||||
<div v-if="rowsOf(source).length === 0" class="hint">尚未提取任何页面。</div>
|
||||
|
||||
<button type="button" class="pg pg--add" @click="emit('add-page', source)">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
|
||||
<span class="pg__mark" v-html="navIconSvg('plus', 13, 'icon-xs')"></span>
|
||||
添加子页面
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="sources.length === 0" class="tree__empty">
|
||||
<template v-if="query">没有匹配“{{ query }}”的页面。</template>
|
||||
<template v-else>还没有知识来源。</template>
|
||||
<span class="tree__empty-mark" v-html="navIconSvg('folder', 20, 'icon')"></span>
|
||||
<p class="tree__empty-title">{{ query ? '没有匹配的内容' : '还没有知识来源' }}</p>
|
||||
<p class="tree__empty-text">
|
||||
<template v-if="query">没有来源或页面包含“{{ query }}”。</template>
|
||||
<template v-else>添加网页、PDF 或手写页面,AI 才能据此作答。</template>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tree {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tree__search {
|
||||
margin: 0 12px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 14px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 10px;
|
||||
color: var(--muted2);
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
flex: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--muted);
|
||||
cursor: text;
|
||||
transition:
|
||||
border-color var(--t-fast),
|
||||
box-shadow var(--t-fast);
|
||||
}
|
||||
|
||||
.tree__search:hover {
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
|
||||
.tree__search:focus-within {
|
||||
border-color: var(--line-3);
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
|
||||
.tree__search-ic {
|
||||
display: inline-flex;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.tree__search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
padding: 10px 0;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tree__search input::placeholder {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.tree__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
padding: 0 10px 14px;
|
||||
}
|
||||
|
||||
.group + .group {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.group__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 8px 7px;
|
||||
}
|
||||
|
||||
.group__title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.group__count {
|
||||
font-family: var(--font-num);
|
||||
font-size: 11.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.src {
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.src__row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 9px;
|
||||
padding-right: 6px;
|
||||
gap: 2px;
|
||||
padding-right: 5px;
|
||||
border-radius: var(--radius-md);
|
||||
transition:
|
||||
background-color var(--t-fast),
|
||||
box-shadow var(--t-fast);
|
||||
}
|
||||
|
||||
.src__row:hover {
|
||||
background: var(--o04);
|
||||
background: color-mix(in srgb, var(--surface) 62%, transparent);
|
||||
}
|
||||
|
||||
/* Same paper-card treatment as a current nav item. */
|
||||
.src__row[aria-current='true'] {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm), 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.src__toggle {
|
||||
@@ -257,52 +388,63 @@ const MAX_AVATARS = 3
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
padding: 8px 6px 8px 8px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chev {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted2);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.chev-spacer {
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.src__toggle:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chev--open {
|
||||
.src__chev {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--faint);
|
||||
transition: transform var(--t-fast);
|
||||
}
|
||||
|
||||
.src__chev--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.src__glyph {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
.src__chev-spacer {
|
||||
width: 13px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.src__glyph--p { background: var(--coral-bg); color: var(--c-coral); }
|
||||
.src__glyph--s { background: var(--teal-bg); color: var(--c-teal); }
|
||||
.src__glyph--t { background: var(--purple-bg, var(--o08)); color: var(--c-purple); }
|
||||
.src__mark {
|
||||
--s: 30px;
|
||||
width: var(--s);
|
||||
height: var(--s);
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
transition:
|
||||
background-color var(--t-fast),
|
||||
color var(--t-fast),
|
||||
border-color var(--t-fast);
|
||||
}
|
||||
|
||||
.src__row[aria-current='true'] .src__mark {
|
||||
background: var(--ink);
|
||||
border-color: var(--ink);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
.src__mark--busy {
|
||||
background: var(--surface-2);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.src__meta {
|
||||
flex: 1;
|
||||
@@ -311,45 +453,90 @@ const MAX_AVATARS = 3
|
||||
|
||||
.src__name {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.src__count {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--muted2);
|
||||
.src__row[aria-current='true'] .src__name {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.src__sub {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.src__sub-sep {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.src__count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.02em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.src__count--error {
|
||||
color: var(--c-coral);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.src__status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
|
||||
.src__status--synced {
|
||||
background: var(--celadon);
|
||||
}
|
||||
|
||||
.src__status--crawling {
|
||||
background: var(--brass);
|
||||
}
|
||||
|
||||
.src__status--queued {
|
||||
background: var(--indigo);
|
||||
}
|
||||
|
||||
.src__status--error {
|
||||
background: var(--vermilion);
|
||||
}
|
||||
|
||||
.agents {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
flex: none;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.agents__avatar {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--teal-bg);
|
||||
color: var(--c-teal);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border: 1.5px solid var(--surface);
|
||||
margin-left: -6px;
|
||||
background: var(--celadon-soft);
|
||||
color: var(--celadon);
|
||||
border: 1px solid var(--celadon-line);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
margin-left: -5px;
|
||||
}
|
||||
|
||||
.agents__avatar:first-child {
|
||||
@@ -359,95 +546,117 @@ const MAX_AVATARS = 3
|
||||
.agents__more {
|
||||
margin-left: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted2);
|
||||
font-size: 9.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--muted2);
|
||||
}
|
||||
|
||||
.dot--synced { background: var(--c-teal); box-shadow: 0 0 6px var(--c-teal); }
|
||||
.dot--crawling { background: var(--warning-color); }
|
||||
.dot--queued { background: var(--c-purple); }
|
||||
.dot--error { background: var(--c-coral); }
|
||||
|
||||
.src__add,
|
||||
.src__del {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
.src__act {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
color: var(--muted2);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
transition:
|
||||
opacity var(--t-fast),
|
||||
background-color var(--t-fast),
|
||||
color var(--t-fast),
|
||||
border-color var(--t-fast);
|
||||
}
|
||||
|
||||
.src__row:hover .src__add,
|
||||
.src__row:hover .src__del { opacity: 1; }
|
||||
|
||||
.src__add:hover {
|
||||
background: var(--accent-bg-08);
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent-border, var(--o12));
|
||||
.src__row:hover .src__act,
|
||||
.src__row:focus-within .src__act,
|
||||
.src__act:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.src__del:hover {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
border-color: var(--coral-border);
|
||||
.src__act:hover {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--line);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.src__act--danger:hover {
|
||||
background: var(--vermilion-soft);
|
||||
border-color: var(--vermilion-line);
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
/* Touch devices have no hover — keep the row actions permanently visible. */
|
||||
@media (hover: none) {
|
||||
.src__act {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.src__pages {
|
||||
padding: 2px 0 6px 18px;
|
||||
margin: 2px 0 6px 20px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.src__pages-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 8px 4px;
|
||||
}
|
||||
|
||||
.src__pages-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.src__pages-count {
|
||||
font-family: var(--font-num);
|
||||
font-size: 10.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.pg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
padding: 6px 8px;
|
||||
margin: 1px 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color var(--t-fast),
|
||||
box-shadow var(--t-fast);
|
||||
}
|
||||
|
||||
.pg:hover { background: var(--o04); }
|
||||
.pg--active { background: var(--accent-bg-08); }
|
||||
|
||||
.pg--add {
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--muted2);
|
||||
.pg:hover {
|
||||
background: color-mix(in srgb, var(--surface) 62%, transparent);
|
||||
}
|
||||
|
||||
.pg--add:hover {
|
||||
color: var(--accent-ink);
|
||||
.pg[aria-current='true'] {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm), 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.pg__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--c-teal);
|
||||
.pg__mark {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.pg[aria-current='true'] .pg__mark {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.pg__title {
|
||||
@@ -456,46 +665,115 @@ const MAX_AVATARS = 3
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text3);
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pg--active .pg__title {
|
||||
color: var(--text);
|
||||
.pg[aria-current='true'] .pg__title {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pg__words {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
flex: none;
|
||||
font-family: var(--font-num);
|
||||
font-size: 10.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.src__hint {
|
||||
padding: 8px 10px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted2);
|
||||
.pg--add {
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.src__hint--error { color: var(--c-coral); }
|
||||
.pg--add:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.hint {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint--error {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.src__error {
|
||||
margin: 2px 10px 6px 44px;
|
||||
margin: 2px 6px 6px 42px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--c-coral);
|
||||
background: var(--coral-bg);
|
||||
border: 1px solid var(--coral-border);
|
||||
border-radius: 8px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--vermilion);
|
||||
background: var(--vermilion-soft);
|
||||
border: 1px solid var(--vermilion-line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.tree__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.tree__skeleton {
|
||||
display: block;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface) 55%, transparent);
|
||||
}
|
||||
|
||||
.tree__empty {
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--muted2);
|
||||
padding: 44px 16px 32px;
|
||||
}
|
||||
|
||||
.tree__empty-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.tree__empty-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.tree__empty-text {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
max-width: 26ch;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -15,80 +15,158 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts" name="AppSidebar">
|
||||
import { computed } from 'vue'
|
||||
import BrandMark from '@/components/common/BrandMark.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useNavItems, navIconSvg } from './navItems'
|
||||
|
||||
import SidebarToggle from './SidebarToggle.vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
isCollapsed: boolean
|
||||
userName?: string
|
||||
userRole?: string
|
||||
isOnline?: boolean
|
||||
statusUpdating?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle'): void
|
||||
(e: 'navigate'): void
|
||||
(e: 'logout'): void
|
||||
(e: 'toggle-status'): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const { navItems } = useNavItems()
|
||||
const { navGroups } = useNavItems()
|
||||
|
||||
const isActiveRoute = computed(() => (path?: string) => path ? route.path === path : false)
|
||||
|
||||
const handleNavigation = () => {
|
||||
emit('navigate')
|
||||
}
|
||||
|
||||
// 配置 is a disclosure group: it mirrors the prototype's collapsible
|
||||
// settings block and keeps the eight settings links out of the first
|
||||
// impression of the sidebar.
|
||||
const settingsOpen = ref(true)
|
||||
const isSettingsGroup = (section: string) => section === '配置'
|
||||
|
||||
const showUserMenu = ref(false)
|
||||
const toggleUserMenu = () => {
|
||||
if (props.isCollapsed) return
|
||||
showUserMenu.value = !showUserMenu.value
|
||||
}
|
||||
|
||||
const initial = computed(() => (props.userName || '·').trim().slice(0, 1))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ 'collapsed': isCollapsed }">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-container">
|
||||
<div class="logo-mark" aria-hidden="true">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
<span v-if="!isCollapsed" class="logo-text">ReplyPier</span>
|
||||
<aside class="sidebar" :class="{ collapsed: isCollapsed }">
|
||||
<div class="brand">
|
||||
<BrandMark :size="34" />
|
||||
<div v-if="!isCollapsed" class="brand-text">
|
||||
<span class="wordmark">Chatter<i>Mate</i></span>
|
||||
<span class="brand-sub">智能客服工作台</span>
|
||||
</div>
|
||||
<SidebarToggle :isCollapsed="isCollapsed" @toggle="emit('toggle')" />
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
<div v-for="(item, index) in navItems" :key="index">
|
||||
<!-- Section Header -->
|
||||
<div v-if="item.section" class="nav-section nav-section-heading" :class="{ 'collapsed': isCollapsed }">
|
||||
<span v-if="!isCollapsed">{{ item.section }}</span>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<template v-for="group in navGroups" :key="group.section">
|
||||
<div v-if="isSettingsGroup(group.section)" class="nav-group" :class="{ open: settingsOpen }">
|
||||
<button
|
||||
v-if="!isCollapsed"
|
||||
class="nav-label nav-group-trigger"
|
||||
type="button"
|
||||
:aria-expanded="settingsOpen"
|
||||
@click="settingsOpen = !settingsOpen"
|
||||
>
|
||||
<span>{{ group.en }}</span>
|
||||
<em>{{ group.section }}</em>
|
||||
<span class="chev" v-html="navIconSvg('chevronRight', 14)"></span>
|
||||
</button>
|
||||
<div v-else class="nav-label"><span></span></div>
|
||||
|
||||
<!-- Nav Item -->
|
||||
<router-link v-else-if="item.to" :to="item.to" class="nav-item"
|
||||
:class="{ 'active': isActiveRoute(item.to) }"
|
||||
<div class="nav-sub">
|
||||
<div>
|
||||
<router-link
|
||||
v-for="item in group.items.filter((i) => i.to)"
|
||||
:key="item.to"
|
||||
:to="(item.to as string)"
|
||||
class="nav-item"
|
||||
:aria-current="isActiveRoute(item.to) ? 'page' : undefined"
|
||||
:title="isCollapsed ? item.label : undefined"
|
||||
@click="handleNavigation">
|
||||
<span class="nav-bar" aria-hidden="true"></span>
|
||||
<span class="nav-icon" v-html="navIconSvg(item.icon)"></span>
|
||||
<span v-if="!isCollapsed" class="nav-label">{{ item.label }}</span>
|
||||
@click="handleNavigation"
|
||||
>
|
||||
<span class="nav-icon" v-html="navIconSvg(item.icon, 18)"></span>
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="nav-label">
|
||||
<span>{{ group.section }}</span>
|
||||
<em>{{ group.en }}</em>
|
||||
</div>
|
||||
<router-link
|
||||
v-for="item in group.items.filter((i) => i.to)"
|
||||
:key="item.to"
|
||||
:to="(item.to as string)"
|
||||
class="nav-item"
|
||||
:aria-current="isActiveRoute(item.to) ? 'page' : undefined"
|
||||
:title="isCollapsed ? item.label : undefined"
|
||||
@click="handleNavigation"
|
||||
>
|
||||
<span class="nav-icon" v-html="navIconSvg(item.icon, 18)"></span>
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<button class="me" type="button" @click="toggleUserMenu" :aria-expanded="showUserMenu">
|
||||
<span class="avatar ink" style="--a: 34px">
|
||||
{{ initial }}
|
||||
<i class="presence-dot" :style="{ background: isOnline ? 'var(--celadon)' : 'var(--faint)' }"></i>
|
||||
</span>
|
||||
<div v-if="!isCollapsed">
|
||||
<div class="me-name">{{ userName || '未登录' }}</div>
|
||||
<div class="me-role">{{ userRole || '客服' }}</div>
|
||||
</div>
|
||||
<span v-if="!isCollapsed" class="me-chev" v-html="navIconSvg('chevronRight', 14)"></span>
|
||||
</button>
|
||||
|
||||
<div v-if="showUserMenu && !isCollapsed" class="user-menu">
|
||||
<button class="user-menu-item" type="button" :disabled="statusUpdating" @click="emit('toggle-status')">
|
||||
<span class="me-role" style="margin: 0">接待状态</span>
|
||||
<span class="chip" :class="isOnline ? 'chip-ai' : 'chip-quiet'">
|
||||
<i class="d"></i>{{ isOnline ? '在线' : '离线' }}
|
||||
</span>
|
||||
</button>
|
||||
<button class="user-menu-item" type="button" @click="emit('logout')">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 252px;
|
||||
background: var(--bg2);
|
||||
border-right: 1px solid var(--o07);
|
||||
width: 244px;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
flex: none;
|
||||
background: var(--paper-2);
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width .22s ease;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
transition: width 0.22s var(--ease);
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -96,189 +174,305 @@ const handleNavigation = () => {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 14px;
|
||||
border-bottom: 1px solid var(--o06);
|
||||
/* ── Brand ─────────────────────────────────────────── */
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Collapsed header: stack logo above the toggle, both centered */
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
gap: 12px;
|
||||
padding: 22px 18px 20px 22px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 3-dot logo mark */
|
||||
.logo-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--accent-solid);
|
||||
border-radius: 10px 10px 10px 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 4.5px;
|
||||
height: 4.5px;
|
||||
background: var(--on-accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 18px 14px;
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.32em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .brand {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 20px 0 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── Nav ───────────────────────────────────────────── */
|
||||
.nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* Hide the scrollbar so it doesn't squeeze the centered icons when collapsed */
|
||||
padding: 6px 14px 14px;
|
||||
/* The rail centers icons; a scrollbar would squeeze them off-centre */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
.nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Collapsed: tighten side padding so icons sit dead-centre in the 76px rail */
|
||||
.sidebar.collapsed .sidebar-nav {
|
||||
padding: 18px 8px;
|
||||
.sidebar.collapsed .nav {
|
||||
padding: 6px 12px 14px;
|
||||
}
|
||||
|
||||
/* Typography comes from the shared .nav-section-heading (components.css) */
|
||||
.nav-section {
|
||||
padding: 0 13px;
|
||||
margin: 4px 0 12px;
|
||||
.nav-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 18px 10px 8px;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.24em;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: default;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Collapsed: section label becomes an invisible spacer, no divider line */
|
||||
.nav-section.collapsed {
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
.nav-group-trigger {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-label em {
|
||||
font-style: normal;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
margin-right: auto;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.nav-group .chev {
|
||||
color: var(--faint);
|
||||
display: inline-flex;
|
||||
transition: transform var(--t-med);
|
||||
}
|
||||
|
||||
.nav-group.open .chev {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.nav-sub {
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows var(--t-med);
|
||||
}
|
||||
|
||||
.nav-sub > div {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nav-group.open .nav-sub {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 11px 13px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-btn);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Collapsed: center the icon, drop the gap */
|
||||
.sidebar.collapsed .nav-item {
|
||||
padding: 11px;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-item:hover:not(.active) {
|
||||
background: var(--o04);
|
||||
gap: 12px;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
margin: 1px 0;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text2);
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
transition: background-color var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: color-mix(in srgb, var(--surface) 60%, transparent);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.nav-item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring-focus);
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
/* Lime accent bar on the active item */
|
||||
.nav-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-solid);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hide the active bar when collapsed */
|
||||
.sidebar.collapsed .nav-item.active .nav-bar {
|
||||
opacity: 0;
|
||||
.nav-item[aria-current='page'] {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-sm), 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
color: var(--muted);
|
||||
transition: color var(--t-fast);
|
||||
}
|
||||
|
||||
.nav-icon :deep(svg) {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
.nav-item:hover .nav-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.nav-item[aria-current='page'] .nav-icon {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.nav-sub .nav-item {
|
||||
padding-left: 40px;
|
||||
height: 34px;
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ── Rail ──────────────────────────────────────────── */
|
||||
.sidebar.collapsed .nav-label {
|
||||
padding: 0;
|
||||
height: 21px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-label > * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-label::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 10px;
|
||||
height: 1px;
|
||||
background: var(--line-2);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-sub .nav-item {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item > span:last-child,
|
||||
.sidebar.collapsed .nav-group .chev {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Footer / current user ─────────────────────────── */
|
||||
.sidebar-foot {
|
||||
position: relative;
|
||||
padding: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--t-fast);
|
||||
}
|
||||
|
||||
.me:hover {
|
||||
background: color-mix(in srgb, var(--surface) 60%, transparent);
|
||||
}
|
||||
|
||||
.me-name {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Small laptops (1025px - 1280px) */
|
||||
@media (max-width: 1280px) and (min-width: 1025px) {
|
||||
.sidebar {
|
||||
width: 248px;
|
||||
position: relative;
|
||||
.me-role {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 76px;
|
||||
}
|
||||
.me-chev {
|
||||
margin-left: auto;
|
||||
color: var(--faint);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Tablets and below - Overlay mode */
|
||||
.sidebar.collapsed .me {
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .me-chev {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: calc(100% - 6px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 6px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-menu-item:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.user-menu-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Overlay mode (tablets and below) ───────────────── */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
@@ -286,67 +480,68 @@ const handleNavigation = () => {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 8px 0 32px rgba(0,0,0,.4);
|
||||
width: 272px;
|
||||
max-width: 85vw;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
transform: translateX(-100%);
|
||||
width: 252px;
|
||||
/* An off-canvas panel must not keep painting its shadow over the page.
|
||||
Sliding it out moves the box, not the shadow's reach: with an 8px
|
||||
offset and 32px blur, roughly 24px of it still lands on the page's
|
||||
left edge, above the content at z-index 1000. On the dark theme that
|
||||
is black on near-black and invisible, which is why it went unnoticed;
|
||||
on the light theme it reads as a grey band down the side. */
|
||||
width: 272px;
|
||||
max-width: 85vw;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* In overlay mode the panel is full-width — restore expanded layout */
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
/* The off-canvas panel is full-width when it slides in, so restore the
|
||||
expanded layout regardless of the collapse flag. */
|
||||
.sidebar.collapsed .brand {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 22px 20px;
|
||||
justify-content: flex-start;
|
||||
padding: 22px 18px 20px 22px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .brand-text,
|
||||
.sidebar.collapsed .me > div,
|
||||
.sidebar.collapsed .me-chev {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-label {
|
||||
padding: 18px 10px 8px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-label > * {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-label::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item {
|
||||
padding: 11px 13px;
|
||||
gap: 13px;
|
||||
justify-content: flex-start;
|
||||
padding: 0 10px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item > span:last-child {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-sub .nav-item {
|
||||
padding-left: 40px;
|
||||
height: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small mobile */
|
||||
@media (max-width: 480px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.sidebar.collapsed {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: var(--space-md) var(--space-lg) var(--space-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -40,17 +40,63 @@ export const NAV_ICONS: Record<string, string> = {
|
||||
moon: '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
|
||||
sun: '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>',
|
||||
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
|
||||
// ── added with the 墨与瓷 redesign ──
|
||||
mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||||
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
eye: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
|
||||
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
|
||||
arrowUpRight: '<path d="M7 7h10v10"/><path d="M7 17 17 7"/>',
|
||||
star: '<path d="M11.52 2.3a.53.53 0 0 1 .95 0l2.31 4.68a2.12 2.12 0 0 0 1.6 1.16l5.17.76a.53.53 0 0 1 .29.9l-3.74 3.64a2.12 2.12 0 0 0-.61 1.88l.88 5.14a.53.53 0 0 1-.77.56l-4.62-2.43a2.12 2.12 0 0 0-1.97 0L6.4 21.01a.53.53 0 0 1-.77-.56l.88-5.14a2.12 2.12 0 0 0-.61-1.88L2.16 9.8a.53.53 0 0 1 .29-.9l5.17-.76a2.12 2.12 0 0 0 1.6-1.16z"/>',
|
||||
book: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
transfer: '<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>',
|
||||
sparkles: '<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3Z"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
checks: '<path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/>',
|
||||
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
send: '<path d="M14.54 21.69a.5.5 0 0 0 .94-.02L21.97 2.69a.5.5 0 0 0-.65-.65L2.33 8.53a.5.5 0 0 0-.02.94l7.93 3.18a2 2 0 0 1 1.11 1.11z"/><path d="m21.85 2.15-10.94 10.94"/>',
|
||||
filter: '<path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/>',
|
||||
headset: '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>',
|
||||
panel: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/>',
|
||||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
chevronDown: '<path d="m6 9 6 6 6-6"/>',
|
||||
chevronsUpDown: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
|
||||
help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
|
||||
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
sliders: '<line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="2" x2="6" y1="14" y2="14"/><line x1="10" x2="14" y1="8" y2="8"/><line x1="18" x2="22" y1="16" y2="16"/>',
|
||||
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
|
||||
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
|
||||
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>',
|
||||
folder: '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>',
|
||||
edit: '<path d="M12 20h9"/><path d="M16.38 3.62a1 1 0 0 1 3 3L7.37 18.64a2 2 0 0 1-.86.5l-2.87.84a.5.5 0 0 1-.62-.62l.84-2.87a2 2 0 0 1 .5-.86z"/>',
|
||||
trash: '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
|
||||
shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
|
||||
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
|
||||
code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
|
||||
quote: '<path d="M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/><path d="M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z"/>',
|
||||
search: '<circle cx="11" cy="11" r="7.5"/><path d="m20.5 20.5-4.2-4.2"/>',
|
||||
message: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
|
||||
cpu: '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>',
|
||||
building: '<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01"/><path d="M16 6h.01"/><path d="M12 6h.01"/><path d="M12 10h.01"/><path d="M12 14h.01"/><path d="M16 10h.01"/><path d="M16 14h.01"/><path d="M8 10h.01"/><path d="M8 14h.01"/>',
|
||||
plug: '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
|
||||
copy: '<rect x="8" y="8" width="14" height="14" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
}
|
||||
|
||||
// Icons are static — cache built strings so hot paths (bottom nav re-renders
|
||||
// on every route change) don't rebuild them.
|
||||
const svgCache = new Map<string, string>()
|
||||
|
||||
export const navIconSvg = (name?: string, size = 19) => {
|
||||
const key = `${name}:${size}`
|
||||
export const navIconSvg = (name?: string, size = 19, className?: string) => {
|
||||
const key = `${name}:${size}:${className ?? ''}`
|
||||
let svg = svgCache.get(key)
|
||||
if (!svg) {
|
||||
svg = `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${name === 'analytics' ? 2 : 1.7}" stroke-linecap="round" stroke-linejoin="round">${name ? (NAV_ICONS[name] || '') : ''}</svg>`
|
||||
const cls = className ? ` class="${className}"` : ''
|
||||
svg = `<svg${cls} viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${name === 'analytics' ? 2 : 1.7}" stroke-linecap="round" stroke-linejoin="round">${name ? (NAV_ICONS[name] || '') : ''}</svg>`
|
||||
svgCache.set(key, svg)
|
||||
}
|
||||
return svg
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface NavItem {
|
||||
|
||||
export interface NavGroup {
|
||||
section: string
|
||||
/** Latin subtitle shown beside the Chinese section heading */
|
||||
en?: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
@@ -54,20 +56,9 @@ export function useNavItems() {
|
||||
const navGroups = computed<NavGroup[]>(() =>
|
||||
[
|
||||
{
|
||||
section: '主菜单',
|
||||
section: '接待',
|
||||
en: 'Reception',
|
||||
items: [
|
||||
{
|
||||
to: '/ai-agents',
|
||||
icon: 'agents',
|
||||
label: 'AI 客服',
|
||||
show: canAccessPath('/ai-agents'),
|
||||
},
|
||||
{
|
||||
to: '/human-agents',
|
||||
icon: 'humans',
|
||||
label: '人工客服',
|
||||
show: canAccessPath('/human-agents'),
|
||||
},
|
||||
{
|
||||
to: '/conversations',
|
||||
icon: 'inbox',
|
||||
@@ -86,6 +77,18 @@ export function useNavItems() {
|
||||
label: '联系人',
|
||||
show: canAccessPath('/people'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '智能',
|
||||
en: 'Intelligence',
|
||||
items: [
|
||||
{
|
||||
to: '/ai-agents',
|
||||
icon: 'agents',
|
||||
label: 'AI 客服',
|
||||
show: canAccessPath('/ai-agents'),
|
||||
},
|
||||
{
|
||||
to: '/knowledge',
|
||||
icon: 'knowledge',
|
||||
@@ -98,6 +101,18 @@ export function useNavItems() {
|
||||
label: '帮助中心',
|
||||
show: canAccessPath('/faq'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '团队',
|
||||
en: 'Team',
|
||||
items: [
|
||||
{
|
||||
to: '/human-agents',
|
||||
icon: 'humans',
|
||||
label: '人工客服',
|
||||
show: canAccessPath('/human-agents'),
|
||||
},
|
||||
{
|
||||
to: '/analytics',
|
||||
icon: 'analytics',
|
||||
@@ -107,7 +122,8 @@ export function useNavItems() {
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '设置',
|
||||
section: '配置',
|
||||
en: 'Settings',
|
||||
items: [
|
||||
{
|
||||
to: '/settings/organization',
|
||||
|
||||
@@ -21,7 +21,7 @@ export type ThemeMode = 'dark' | 'light' | 'system'
|
||||
|
||||
const STORAGE_KEY = 'rp-theme'
|
||||
|
||||
const stored = (typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY)) || 'dark'
|
||||
const stored = (typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY)) || 'light'
|
||||
const mode = ref<ThemeMode>(stored === 'light' || stored === 'system' ? (stored as ThemeMode) : 'dark')
|
||||
|
||||
const prefersDark = (): boolean =>
|
||||
|
||||
@@ -17,13 +17,14 @@ limitations under the License.
|
||||
import { computed } from 'vue'
|
||||
import type { AgentCustomization } from '../types/widget'
|
||||
import { isColorDark } from '../types/widget'
|
||||
import { resolveAvatarImage } from '../utils/orb'
|
||||
import { resolveWidgetUploadUrl } from '../webclient/widget-env'
|
||||
|
||||
// Themed colours now come from the shared design tokens (widget-theme.ts →
|
||||
// `--rp-*` vars set on `.chat-container`). These computeds return `var(--rp-*)`
|
||||
// references so the widget's existing inline styles resolve to the token values,
|
||||
// while user overrides are folded into the vars by `themeCssVars()`.
|
||||
export function useWidgetStyles(customization: { value: AgentCustomization }) {
|
||||
export function useWidgetStyles(customization: { value: AgentCustomization }, agentName: { value: string } = { value: '' }) {
|
||||
const chatStyles = computed(() => ({
|
||||
backgroundColor: 'var(--rp-card)',
|
||||
color: 'var(--rp-text)'
|
||||
@@ -55,7 +56,7 @@ export function useWidgetStyles(customization: { value: AgentCustomization }) {
|
||||
|
||||
// Signed S3/CDN URLs pass through; local paths are resolved against the
|
||||
// runtime API origin (photo_url already carries the /api/v1 prefix).
|
||||
const photoUrl = computed(() => resolveWidgetUploadUrl(customization.value.photo_url))
|
||||
const photoUrl = computed(() => resolveWidgetUploadUrl(resolveAvatarImage(agentName.value, customization.value)))
|
||||
|
||||
const shadowStyle = computed(() => {
|
||||
const bgColor = customization.value.chat_background_color || '#ffffff'
|
||||
|
||||
@@ -22,6 +22,6 @@ limitations under the License.
|
||||
* value as the pre-JS default.
|
||||
*/
|
||||
export const THEME_COLORS = {
|
||||
dark: '#0B0C10',
|
||||
light: '#EEF0F4',
|
||||
dark: '#121211',
|
||||
light: '#F4F1EB',
|
||||
} as const
|
||||
@@ -37,7 +37,7 @@ import { fab } from '@fortawesome/free-brands-svg-icons'
|
||||
/* Add icons to the library */
|
||||
library.add(fas, far, fab)
|
||||
|
||||
const savedTheme = localStorage.getItem('rp-theme') ?? 'dark'
|
||||
const savedTheme = localStorage.getItem('rp-theme') ?? 'light'
|
||||
const resolvedTheme = savedTheme === 'system'
|
||||
? (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: savedTheme
|
||||
|
||||
@@ -14,91 +14,56 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Procedural "aurora" gradient orbs used as an avatar alternative to an uploaded photo.
|
||||
// Colors are hardcoded hex (not CSS vars) so the orb renders identically in the dashboard,
|
||||
// the preview panel, and the isolated widget bundle (which has no access to app design tokens).
|
||||
import boat from '../assets/avatars/paper-boat.svg?raw'
|
||||
import letter from '../assets/avatars/paper-letter.svg?raw'
|
||||
import pinwheel from '../assets/avatars/paper-pinwheel.svg?raw'
|
||||
import book from '../assets/avatars/paper-book.svg?raw'
|
||||
import plane from '../assets/avatars/paper-plane.svg?raw'
|
||||
import kite from '../assets/avatars/paper-kite.svg?raw'
|
||||
import heart from '../assets/avatars/paper-heart.svg?raw'
|
||||
import house from '../assets/avatars/paper-house.svg?raw'
|
||||
import leaf from '../assets/avatars/paper-leaf.svg?raw'
|
||||
import inkBoat from '../assets/avatars/paper-ink-boat.svg?raw'
|
||||
|
||||
interface OrbPalette {
|
||||
stops: string
|
||||
glow: string
|
||||
}
|
||||
// Keep the orb exports and stored variant indexes compatible with existing agents.
|
||||
// The artwork is now the same flat paper-craft SVG family in every render path.
|
||||
const PAPER_AVATARS = [boat, letter, pinwheel, book, plane, kite, heart, house, leaf, inkBoat]
|
||||
export const PAPER_AVATAR_NAMES = ['纸舟', '信笺', '风车', '书页', '纸翼', '风筝', '折心', '纸屋', '新叶', '墨舟']
|
||||
export const ORB_PALETTE_COUNT = PAPER_AVATARS.length
|
||||
|
||||
// Each entry is a visibly distinct colored orb the user can choose from.
|
||||
const ORB_PALETTES: OrbPalette[] = [
|
||||
{ stops: '#9D8CFF, #5FE3D6, #C9F24E', glow: 'rgba(157,140,255,0.45)' }, // aurora (default)
|
||||
{ stops: '#FF8A73, #9D8CFF, #5FE3D6', glow: 'rgba(255,138,115,0.40)' }, // coral
|
||||
{ stops: '#5FE3D6, #C9F24E, #9D8CFF', glow: 'rgba(95,227,214,0.40)' }, // teal
|
||||
{ stops: '#C9F24E, #5FE3D6, #FF8A73', glow: 'rgba(201,242,78,0.35)' }, // lime
|
||||
{ stops: '#6EA8FF, #9D8CFF, #5FE3D6', glow: 'rgba(110,168,255,0.42)' }, // blue
|
||||
{ stops: '#FF7AC6, #9D8CFF, #6EA8FF', glow: 'rgba(255,122,198,0.42)' }, // pink
|
||||
{ stops: '#FF8A73, #FFC857, #FF7AC6', glow: 'rgba(255,200,87,0.40)' }, // sunset
|
||||
{ stops: '#7C5CFF, #B388FF, #5FE3D6', glow: 'rgba(124,92,255,0.45)' }, // violet
|
||||
{ stops: '#0EA5A5, #5FE3D6, #C9F24E', glow: 'rgba(14,165,165,0.40)' }, // emerald
|
||||
{ stops: '#F34611, #FF8A73, #FFC857', glow: 'rgba(243,70,17,0.38)' }, // ember
|
||||
]
|
||||
|
||||
// Number of selectable orb variants (for rendering the picker grid).
|
||||
export const ORB_PALETTE_COUNT = ORB_PALETTES.length
|
||||
|
||||
// Stable palette index from a seed (agent name) so the same agent always gets the same orb
|
||||
// when no variant has been explicitly chosen.
|
||||
export const orbIndexForSeed = (seed: string): number =>
|
||||
(seed || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0) % ORB_PALETTES.length
|
||||
(seed || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0) % ORB_PALETTE_COUNT
|
||||
|
||||
// Inline style object for a specific orb variant by index.
|
||||
export const getOrbStyleAt = (index: number): Record<string, string> => {
|
||||
const p = ORB_PALETTES[((index % ORB_PALETTES.length) + ORB_PALETTES.length) % ORB_PALETTES.length]
|
||||
return {
|
||||
background: `
|
||||
radial-gradient(circle at 32% 28%, rgba(255,255,255,0.22) 0%, transparent 42%),
|
||||
radial-gradient(circle at 68% 72%, rgba(0,0,0,0.25) 0%, transparent 38%),
|
||||
radial-gradient(ellipse at 50% 50%, ${p.stops})
|
||||
`.trim(),
|
||||
boxShadow: `0 4px 28px ${p.glow}, inset 0 1px 0 rgba(255,255,255,0.15)`,
|
||||
const variantIndex = (seed: string, variant: unknown): number => {
|
||||
const index = typeof variant === 'number' && Number.isFinite(variant)
|
||||
? Math.trunc(variant) : orbIndexForSeed(seed)
|
||||
return ((index % ORB_PALETTE_COUNT) + ORB_PALETTE_COUNT) % ORB_PALETTE_COUNT
|
||||
}
|
||||
|
||||
export const orbSvgDataUri = (seed: string, variant?: unknown): string =>
|
||||
`data:image/svg+xml,${encodeURIComponent(PAPER_AVATARS[variantIndex(seed, variant)])}`
|
||||
|
||||
export const getOrbStyleAt = (index: number): Record<string, string> => ({
|
||||
background: `url("${orbSvgDataUri('', index)}") center / cover no-repeat`,
|
||||
borderRadius: '50%',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Inline style for the orb, keyed by a seed (used as the stable default).
|
||||
export const getOrbStyle = (seed: string): Record<string, string> => getOrbStyleAt(orbIndexForSeed(seed))
|
||||
|
||||
// Resolve the orb style for an agent: an explicitly chosen variant wins, otherwise
|
||||
// fall back to the seed-derived default. `variant` is whatever is stored in
|
||||
// customization_metadata.orb_variant (may be undefined / non-numeric).
|
||||
export const resolveOrbStyle = (seed: string, variant: unknown): Record<string, string> => {
|
||||
const idx = typeof variant === 'number' && Number.isFinite(variant) ? variant : orbIndexForSeed(seed)
|
||||
return getOrbStyleAt(idx)
|
||||
}
|
||||
export const resolveOrbStyle = (seed: string, variant: unknown): Record<string, string> =>
|
||||
getOrbStyleAt(variantIndex(seed, variant))
|
||||
|
||||
// Render an orb variant as a self-contained SVG data URI. Storing this in
|
||||
// `photo_url` lets every existing avatar render site (agent list, widget header,
|
||||
// previews) show the orb with no orb-specific logic — they just render the image.
|
||||
export const orbSvgDataUri = (seed: string, variant?: unknown): string => {
|
||||
const idx = typeof variant === 'number' && Number.isFinite(variant) ? variant : orbIndexForSeed(seed)
|
||||
const p = ORB_PALETTES[((idx % ORB_PALETTES.length) + ORB_PALETTES.length) % ORB_PALETTES.length]
|
||||
const [c1, c2, c3] = p.stops.split(',').map((s) => s.trim())
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">' +
|
||||
'<defs>' +
|
||||
'<radialGradient id="o" cx="50%" cy="50%" r="70%">' +
|
||||
`<stop offset="0%" stop-color="${c1}"/>` +
|
||||
`<stop offset="50%" stop-color="${c2}"/>` +
|
||||
`<stop offset="100%" stop-color="${c3}"/>` +
|
||||
'</radialGradient>' +
|
||||
'<radialGradient id="h" cx="32%" cy="28%" r="45%">' +
|
||||
'<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>' +
|
||||
'<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>' +
|
||||
'</radialGradient>' +
|
||||
'<radialGradient id="s" cx="68%" cy="72%" r="42%">' +
|
||||
'<stop offset="0%" stop-color="#000000" stop-opacity="0.3"/>' +
|
||||
'<stop offset="100%" stop-color="#000000" stop-opacity="0"/>' +
|
||||
'</radialGradient>' +
|
||||
'</defs>' +
|
||||
'<circle cx="50" cy="50" r="50" fill="url(#o)"/>' +
|
||||
'<circle cx="50" cy="50" r="50" fill="url(#h)"/>' +
|
||||
'<circle cx="50" cy="50" r="50" fill="url(#s)"/>' +
|
||||
'</svg>'
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
// Regenerate old stored orb data URIs from metadata without changing uploaded photos.
|
||||
// Callers resolve returned local upload paths against their own runtime API origin.
|
||||
export const resolveAvatarImage = (seed: string, customization?: {
|
||||
photo_url?: string | null
|
||||
customization_metadata?: Record<string, unknown> | null
|
||||
}): string => {
|
||||
const meta = customization?.customization_metadata
|
||||
if (meta?.avatar_style === 'orb' || !customization?.photo_url) {
|
||||
return orbSvgDataUri(seed, meta?.orb_variant)
|
||||
}
|
||||
return customization.photo_url
|
||||
}
|
||||
|
||||
// Terminal-style avatar mark: a monospace ">" prompt on a dark, FULL-BLEED tile tinted
|
||||
@@ -106,10 +71,10 @@ export const orbSvgDataUri = (seed: string, variant?: unknown): string => {
|
||||
// avatar container at every render site — circular (header/message) or square — with no
|
||||
// transparent corners. Stored in `photo_url` exactly like the orb, so every avatar
|
||||
// render site shows it with no mark-specific logic.
|
||||
export const TERMINAL_MARK_ACCENT = '#C9F24E'
|
||||
export const TERMINAL_MARK_ACCENT = '#8BB49C'
|
||||
// Only accept simple, safe colour tokens (hex / rgb[a] / hsl[a] / named) before
|
||||
// embedding into the SVG, so a stray quote in a stored value can't break out of the
|
||||
// attribute. Anything else falls back to the Terminal lime.
|
||||
// attribute. Anything else falls back to the muted celadon.
|
||||
const SAFE_COLOR = /^(#[0-9a-f]{3,8}|rgba?\([\d.,\s%]+\)|hsla?\([\d.,\s%]+\)|[a-z]+)$/i
|
||||
export const terminalMarkSvgDataUri = (accent: string = TERMINAL_MARK_ACCENT): string => {
|
||||
const c = accent && SAFE_COLOR.test(accent.trim()) ? accent.trim() : TERMINAL_MARK_ACCENT
|
||||
|
||||
@@ -21,6 +21,8 @@ import AnalyticsDashboard from '@/components/analytics/AnalyticsDashboard.vue'
|
||||
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<div class="page-inner">
|
||||
<AnalyticsDashboard />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
import ConversationsList from '@/components/conversations/ConversationsList.vue'
|
||||
@@ -40,6 +40,20 @@ const initialSessionId = ref<string | null>(
|
||||
typeof route.query.session === 'string' ? route.query.session : null
|
||||
)
|
||||
|
||||
// The details column is a real third column above ~1280px; narrower desktop
|
||||
// widths open it as an overlay instead, so a chat never gets squeezed into a
|
||||
// sliver. Selecting a chat opens the panel automatically only where it fits.
|
||||
const DETAILS_AUTO_QUERY = '(min-width: 1281px)'
|
||||
const detailsAutoOpen = ref(
|
||||
typeof window !== 'undefined' && window.matchMedia
|
||||
? window.matchMedia(DETAILS_AUTO_QUERY).matches
|
||||
: true
|
||||
)
|
||||
let detailsMediaQuery: MediaQueryList | null = null
|
||||
const syncDetailsAutoOpen = (event: MediaQueryListEvent) => {
|
||||
detailsAutoOpen.value = event.matches
|
||||
}
|
||||
|
||||
// On mobile the ?session= query drives which pane is visible (list vs chat) so
|
||||
// the hardware back button walks chat → list. It's also kept reactive for push
|
||||
// deep-links into an already-open app.
|
||||
@@ -116,6 +130,11 @@ const loadingAgents = ref(false)
|
||||
const selectedChatInfo = ref<ChatDetail | null>(null)
|
||||
const showChatInfo = ref(false)
|
||||
|
||||
// The details column/overlay is only offered while a chat is loaded.
|
||||
const detailsVisible = computed(
|
||||
() => !isMobile.value && showChatInfo.value && !!selectedChatInfo.value
|
||||
)
|
||||
|
||||
// Ref for ConversationsList component
|
||||
const conversationsListRef = ref<InstanceType<typeof ConversationsList> | null>(null)
|
||||
|
||||
@@ -123,6 +142,21 @@ const conversationsListRef = ref<InstanceType<typeof ConversationsList> | null>(
|
||||
const loadedCount = computed(() => conversations.value?.length || 0)
|
||||
const totalItems = computed(() => totalCount.value || loadedCount.value)
|
||||
|
||||
// The queue heading states what the current tab holds. While more pages exist
|
||||
// the count is an honest lower bound ("20+"), not the loaded page pretending to
|
||||
// be the total.
|
||||
const queueCountLabel = computed(() => {
|
||||
if (loading.value && conversations.value.length === 0) return '正在加载…'
|
||||
const count = `${loadedCount.value}${hasMore.value ? '+' : ''}`
|
||||
return statusFilter.value === 'closed' ? `${count} 个已结束` : `${count} 个进行中`
|
||||
})
|
||||
|
||||
const tabCount = computed(() =>
|
||||
loading.value && conversations.value.length === 0
|
||||
? '…'
|
||||
: `${loadedCount.value}${hasMore.value ? '+' : ''}`
|
||||
)
|
||||
|
||||
const loadConversations = async (page = 1, loadMore = false) => {
|
||||
error.value = ''
|
||||
|
||||
@@ -259,6 +293,17 @@ onMounted(() => {
|
||||
loadConversations(1)
|
||||
loadUsers()
|
||||
loadAgents()
|
||||
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
detailsMediaQuery = window.matchMedia(DETAILS_AUTO_QUERY)
|
||||
detailsAutoOpen.value = detailsMediaQuery.matches
|
||||
detailsMediaQuery.addEventListener('change', syncDetailsAutoOpen)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
detailsMediaQuery?.removeEventListener('change', syncDetailsAutoOpen)
|
||||
detailsMediaQuery = null
|
||||
})
|
||||
|
||||
const handleChatUpdated = (chatDetail: ChatDetail) => {
|
||||
@@ -298,8 +343,9 @@ const handleChatUpdated = (chatDetail: ChatDetail) => {
|
||||
|
||||
const handleChatSelected = (chatDetail: ChatDetail) => {
|
||||
selectedChatInfo.value = chatDetail
|
||||
// Only auto-show if not already visible
|
||||
if (!showChatInfo.value) {
|
||||
// Auto-open only where the column has room; on narrower desktops the header
|
||||
// toggle opens it as an overlay instead.
|
||||
if (!showChatInfo.value && detailsAutoOpen.value) {
|
||||
showChatInfo.value = true
|
||||
}
|
||||
}
|
||||
@@ -343,7 +389,7 @@ const handleChatBack = () => {
|
||||
}
|
||||
|
||||
// Handle chat closed from ChatInfoPanel
|
||||
const handleChatClosed = (_sessionId?: string) => {
|
||||
const handleChatClosed = () => {
|
||||
showChatInfo.value = false
|
||||
selectedChatInfo.value = null
|
||||
if (conversationsListRef.value) {
|
||||
@@ -358,19 +404,24 @@ const handleChatClosed = (_sessionId?: string) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardLayout :hideHeader="true">
|
||||
<DashboardLayout :flush="true">
|
||||
<div class="conversations-page" :class="{ 'mobile-chat-open': mobileChatOpen }">
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<!-- Queue heading: the top of the 344px column. On desktop it overlays the
|
||||
wall above the scrolling queue so the thread header can start at the
|
||||
very top; on phones it joins the normal flow. -->
|
||||
<header class="page-header queue-head">
|
||||
<div class="inbox-title">
|
||||
<h1>会话</h1>
|
||||
<div class="header-actions">
|
||||
<span class="queue-count">{{ queueCountLabel }}</span>
|
||||
<div class="head-actions">
|
||||
<button
|
||||
v-if="whatsappAccounts.length"
|
||||
class="new-conversation-btn"
|
||||
class="icon-btn sm bordered new-chat-btn"
|
||||
title="新建 WhatsApp 会话"
|
||||
aria-label="新建 WhatsApp 会话"
|
||||
@click="showNewConversation = true"
|
||||
>
|
||||
<font-awesome-icon :icon="['fab', 'whatsapp']" />
|
||||
新建会话
|
||||
</button>
|
||||
<ConversationFilters
|
||||
:showFilters="showFilters"
|
||||
@@ -384,24 +435,38 @@ const handleChatClosed = (_sessionId?: string) => {
|
||||
@clear="handleClearFilters"
|
||||
@update:filterValues="filterValues = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="seg" role="tablist" aria-label="会话状态">
|
||||
<button
|
||||
@click="toggleChatInfo"
|
||||
class="info-toggle-btn"
|
||||
:class="{ active: showChatInfo }"
|
||||
aria-label="切换会话信息"
|
||||
:disabled="!selectedChatInfo"
|
||||
role="tab"
|
||||
:aria-selected="statusFilter === 'open'"
|
||||
@click="updateFilter('open')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
进行中
|
||||
<span v-if="statusFilter === 'open'" class="count">{{ tabCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
:aria-selected="statusFilter === 'closed'"
|
||||
@click="updateFilter('closed')"
|
||||
>
|
||||
已结束
|
||||
<span v-if="statusFilter === 'closed'" class="count">{{ tabCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Promo usage nudge for the workspace owner (phones, list pane only).
|
||||
In the mobile flow it sits between the queue heading and the queue;
|
||||
a desktop grid child would steal the single bounded row. -->
|
||||
<PromoInboxCard
|
||||
v-if="isMobile && !mobileChatOpen && hasEnterpriseModule"
|
||||
compact
|
||||
class="promo-inbox-card"
|
||||
/>
|
||||
|
||||
<NewWhatsAppConversation
|
||||
v-if="showNewConversation"
|
||||
:accounts="whatsappAccounts"
|
||||
@@ -409,16 +474,7 @@ const handleChatClosed = (_sessionId?: string) => {
|
||||
@started="onConversationStarted"
|
||||
/>
|
||||
|
||||
<!-- Promo usage nudge for the workspace owner (phones, list pane only).
|
||||
Above the grid: a grid child would take its single bounded row and
|
||||
push the list into an unbounded one (see .main-content). -->
|
||||
<PromoInboxCard
|
||||
v-if="isMobile && !mobileChatOpen && hasEnterpriseModule"
|
||||
compact
|
||||
class="promo-inbox-card"
|
||||
/>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="main-content" :class="{ 'with-details': detailsVisible }">
|
||||
<ConversationsList
|
||||
ref="conversationsListRef"
|
||||
:conversations="conversations"
|
||||
@@ -439,12 +495,12 @@ const handleChatClosed = (_sessionId?: string) => {
|
||||
@chat-selected="handleChatSelected"
|
||||
@select="openSession"
|
||||
@back="handleChatBack"
|
||||
@info="openMobileInfo"
|
||||
@info="toggleChatInfo"
|
||||
@clear-unread="() => {}"
|
||||
/>
|
||||
|
||||
<ChatInfoPanel
|
||||
v-if="isMobile ? mobileInfoOpen : showChatInfo"
|
||||
v-if="isMobile ? mobileInfoOpen : detailsVisible"
|
||||
:class="{ 'mobile-fullscreen': mobileInfoOpen }"
|
||||
:chatInfo="selectedChatInfo"
|
||||
:users="users"
|
||||
@@ -453,154 +509,212 @@ const handleChatClosed = (_sessionId?: string) => {
|
||||
@chatUpdated="handleChatUpdated"
|
||||
@chatClosed="handleChatClosed"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The workspace owns its padding and its three columns. Widths live on the
|
||||
page so both the heading and the queue can read the same values. */
|
||||
.conversations-page {
|
||||
--queue-w: 344px;
|
||||
--queue-head-h: 120px;
|
||||
--details-w: 316px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background: var(--paper);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.promo-inbox-card {
|
||||
flex-shrink: 0;
|
||||
margin: 0 16px 8px;
|
||||
}
|
||||
|
||||
.conversations-page {
|
||||
/* ── Queue heading ─────────────────────────────────── */
|
||||
.queue-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
padding: 20px 20px 14px;
|
||||
background: var(--paper);
|
||||
border-right: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.inbox-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inbox-title h1 {
|
||||
flex: none;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
color: var(--ink);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.queue-count {
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.new-chat-btn :deep(svg) {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.queue-head .seg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.queue-head .seg button {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding: 0 9px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* ── Three columns ─────────────────────────────────── */
|
||||
.main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
/* Bound the implicit row: an auto row is content-sized, so the list/chat
|
||||
panes inherit no height limit and their inner overflow never scrolls. */
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
transition: all 0.3s ease;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.main-content:not(:has(.chat-info-sidebar)) {
|
||||
grid-template-columns: 1fr;
|
||||
.main-content.with-details {
|
||||
grid-template-columns: minmax(0, 1fr) var(--details-w);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: calc(14px + var(--safe-top)) var(--space-lg) 14px;
|
||||
border-bottom: 1px solid var(--o08);
|
||||
background: var(--bg2);
|
||||
/* The heading is pinned above the queue column, matching its width; the queue
|
||||
itself reserves the same height at the top of its scroll pane. */
|
||||
@media (min-width: 769px) {
|
||||
.queue-head {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--queue-w);
|
||||
height: var(--queue-head-h);
|
||||
z-index: 5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@media (max-width: 1440px) {
|
||||
.conversations-page {
|
||||
--queue-w: 332px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
@media (max-width: 1280px) {
|
||||
.conversations-page {
|
||||
--queue-w: 316px;
|
||||
--details-w: 300px;
|
||||
}
|
||||
|
||||
.new-conversation-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
border: none;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
/* Not enough width for three columns: the details open as an overlay and
|
||||
leave the thread its full width. */
|
||||
.main-content.with-details {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
.main-content.with-details > .chat-info-sidebar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(320px, 86vw);
|
||||
height: auto;
|
||||
border-left: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 30;
|
||||
}
|
||||
}
|
||||
|
||||
.info-toggle-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 10px;
|
||||
background: var(--o05);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
@media (max-width: 1024px) {
|
||||
.conversations-page {
|
||||
--queue-w: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-toggle-btn:hover {
|
||||
background: var(--o10);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.info-toggle-btn.active {
|
||||
background: rgba(201,242,78,.12);
|
||||
color: var(--accent-ink);
|
||||
border-color: rgba(201,242,78,.3);
|
||||
}
|
||||
|
||||
.info-toggle-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.info-toggle-btn:disabled:hover {
|
||||
background: var(--o05);
|
||||
color: var(--muted);
|
||||
border-color: var(--o10);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
/* ── Mobile: one pane at a time ────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
align-items: flex-start;
|
||||
.conversations-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-self: flex-end;
|
||||
.queue-head {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 14px 16px 12px;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
grid-template-columns: 1fr !important;
|
||||
.inbox-title h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
/* Full-screen chat pane: the chat's own header replaces the page header */
|
||||
.conversations-page.mobile-chat-open .page-header {
|
||||
.main-content,
|
||||
.main-content.with-details {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* The full-screen chat replaces the list heading */
|
||||
.conversations-page.mobile-chat-open .queue-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The desktop info toggle lives in the page header; on mobile it's in the chat */
|
||||
.info-toggle-btn {
|
||||
display: none;
|
||||
.promo-inbox-card {
|
||||
margin: 0 12px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page-header {
|
||||
padding: var(--space-md);
|
||||
.queue-head {
|
||||
padding: 12px 12px 10px;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
.inbox-title h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.queue-count {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,14 +15,17 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { ref, onMounted, nextTick, computed } from 'vue'
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
import UserList from '@/components/human-agent/UserList.vue'
|
||||
import GroupList from '@/components/human-agent/GroupList.vue'
|
||||
import RoleList from '@/components/human-agent/RoleList.vue'
|
||||
import { listUsers } from '@/services/users'
|
||||
import { navIconSvg } from '@/components/layout/navIcons'
|
||||
import { listUsers, getTeamOverview } from '@/services/users'
|
||||
import { listGroups } from '@/services/groups'
|
||||
import { listRoles } from '@/services/roles'
|
||||
import type { TeamKpis } from '@/services/users'
|
||||
import type { User } from '@/types/user'
|
||||
|
||||
type Tab = 'users' | 'groups' | 'roles'
|
||||
const activeTab = ref<Tab>('users')
|
||||
@@ -34,12 +37,83 @@ const rolesCount = ref<number | null>(null)
|
||||
|
||||
const userListRef = ref<InstanceType<typeof UserList> | null>(null)
|
||||
|
||||
const loadCounts = async () => {
|
||||
try { peopleCount.value = (await listUsers()).length } catch { /* noop */ }
|
||||
try { teamsCount.value = (await listGroups()).length } catch { /* noop */ }
|
||||
try { rolesCount.value = (await listRoles()).length } catch { /* noop */ }
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team metrics
|
||||
//
|
||||
// The page (not the list) owns the KPI strip: it stays put while the tabs
|
||||
// switch between 人员 / 团队 / 角色. DATA: GET /users/team-overview when the
|
||||
// deployment exposes it, otherwise a derived subset from GET /users — the
|
||||
// same graceful fallback the list uses for its rows.
|
||||
// ---------------------------------------------------------------------------
|
||||
const people = ref<User[]>([])
|
||||
const peopleLoaded = ref(false)
|
||||
const overviewKpis = ref<TeamKpis | null>(null)
|
||||
const statsLoading = ref(true)
|
||||
|
||||
const isAdminRole = (roleName?: string | null) => !!roleName && /admin/i.test(roleName)
|
||||
|
||||
const derivedKpis = computed<TeamKpis | null>(() => {
|
||||
// Only claim a figure once the roster actually came back: before that a 0
|
||||
// would read as "nobody on the team" instead of "not loaded yet".
|
||||
if (!peopleLoaded.value) return null
|
||||
const admins = people.value.filter((u) => isAdminRole(u.role?.name)).length
|
||||
return {
|
||||
team_size: people.value.length,
|
||||
admins,
|
||||
agents: people.value.length - admins,
|
||||
online_now: people.value.filter((u) => u.is_online).length,
|
||||
// The users endpoint carries no live-chat figures; 0 reads as "unknown"
|
||||
// and the strip renders a dash for those two cells.
|
||||
active_chats: 0,
|
||||
total_capacity: 0,
|
||||
waiting_handoff: 0,
|
||||
oldest_wait_minutes: 0,
|
||||
}
|
||||
onMounted(loadCounts)
|
||||
})
|
||||
|
||||
const teamKpis = computed<TeamKpis | null>(() => overviewKpis.value ?? derivedKpis.value)
|
||||
|
||||
const capacityPercent = computed<number | null>(() => {
|
||||
const k = teamKpis.value
|
||||
if (!k || k.total_capacity <= 0) return null
|
||||
return Math.round((k.active_chats / k.total_capacity) * 100)
|
||||
})
|
||||
|
||||
const loadOverviewKpis = async () => {
|
||||
const overview = await getTeamOverview()
|
||||
overviewKpis.value = overview?.kpis ?? null
|
||||
statsLoading.value = false
|
||||
}
|
||||
|
||||
const loadCounts = async () => {
|
||||
try {
|
||||
const list = await listUsers()
|
||||
people.value = list
|
||||
peopleCount.value = list.length
|
||||
peopleLoaded.value = true
|
||||
} catch {
|
||||
/* 计数与指标条只是装饰,接口不可用时保留原值 */
|
||||
}
|
||||
try {
|
||||
teamsCount.value = (await listGroups()).length
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
rolesCount.value = (await listRoles()).length
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
const onChanged = async () => {
|
||||
await loadCounts()
|
||||
await loadOverviewKpis()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadCounts(), loadOverviewKpis()])
|
||||
})
|
||||
|
||||
const onInvite = async () => {
|
||||
activeTab.value = 'users'
|
||||
@@ -50,201 +124,220 @@ const onInvite = async () => {
|
||||
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<div class="human-agent-container">
|
||||
<div class="page-inner human-agent-container">
|
||||
<!-- Page header -->
|
||||
<header class="page-header">
|
||||
<div class="title-block">
|
||||
<h1 class="page-title">人工客服</h1>
|
||||
<p class="page-subtitle">AI 背后的真人团队——对话需要人工接手时,由他们来处理。</p>
|
||||
<header class="page-head reveal" style="--i: 0">
|
||||
<div>
|
||||
<div class="eyebrow">Team · 团队</div>
|
||||
<h1 class="title">人工客服</h1>
|
||||
<p class="lede">
|
||||
AI 身后的真人团队。对话需要人工接手时,按分组与在线状态分配给他们。
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="search-box">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4-4"></path></svg>
|
||||
<input v-model="searchQuery" placeholder="搜索人员…" />
|
||||
</div>
|
||||
<button class="invite-btn" @click="onInvite">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M19 8v6M22 11h-6"></path></svg>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-primary" @click="onInvite">
|
||||
<span v-html="navIconSvg('plus', 16, 'icon')" aria-hidden="true"></span>
|
||||
邀请客服
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pill tabs -->
|
||||
<nav class="tab-pills">
|
||||
<button :class="['tab-pill', { active: activeTab === 'users' }]" @click="activeTab = 'users'">
|
||||
人员 <span class="tab-badge" v-if="peopleCount !== null">{{ peopleCount }}</span>
|
||||
<!-- Team metrics: one container, hairline-divided cells -->
|
||||
<div class="stats reveal" style="--i: 1">
|
||||
<div class="stat">
|
||||
<div class="stat-label">团队规模</div>
|
||||
<div class="stat-value">{{ teamKpis ? teamKpis.team_size : '—' }}</div>
|
||||
<div class="stat-foot">
|
||||
<template v-if="teamKpis">
|
||||
{{ teamKpis.admins }} 位管理员 · {{ teamKpis.agents }} 位客服
|
||||
</template>
|
||||
<span v-else class="muted">{{ statsLoading ? '统计中…' : '暂无数据' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-label">当前在线</div>
|
||||
<div class="stat-value">{{ teamKpis ? teamKpis.online_now : '—' }}</div>
|
||||
<div class="stat-foot">
|
||||
<span v-if="teamKpis && teamKpis.online_now > 0" class="chip chip-ai">
|
||||
<i class="d" aria-hidden="true"></i>可接待转接
|
||||
</span>
|
||||
<span v-else-if="teamKpis" class="chip chip-quiet">全部离线</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-label">实时负载</div>
|
||||
<div class="stat-value">
|
||||
<template v-if="teamKpis && teamKpis.total_capacity > 0">
|
||||
{{ teamKpis.active_chats }}<small>/ {{ teamKpis.total_capacity }}</small>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</div>
|
||||
<div class="stat-foot">
|
||||
<span class="muted">
|
||||
{{ capacityPercent === null ? '暂无容量数据' : `已用容量 ${capacityPercent}%` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-label">等待转接</div>
|
||||
<div
|
||||
class="stat-value"
|
||||
:class="{ 'is-alert': teamKpis !== null }"
|
||||
>
|
||||
{{ teamKpis ? teamKpis.waiting_handoff : '—' }}
|
||||
</div>
|
||||
<div class="stat-foot">
|
||||
<template v-if="teamKpis && teamKpis.waiting_handoff > 0">
|
||||
最长等待
|
||||
<b class="num is-alert">{{ teamKpis.oldest_wait_minutes }} 分钟</b>
|
||||
</template>
|
||||
<span v-else class="muted">{{ teamKpis ? '队列为空' : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs + search -->
|
||||
<div class="toolbar reveal" style="--i: 2">
|
||||
<nav class="tabs tab-pills" role="tablist" aria-label="人工客服视图">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'users'"
|
||||
:class="['tab-pill', { active: activeTab === 'users' }]"
|
||||
@click="activeTab = 'users'"
|
||||
>
|
||||
人员 <span v-if="peopleCount !== null" class="count">{{ peopleCount }}</span>
|
||||
</button>
|
||||
<button :class="['tab-pill', { active: activeTab === 'groups' }]" @click="activeTab = 'groups'">
|
||||
团队 <span class="tab-badge" v-if="teamsCount !== null">{{ teamsCount }}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'groups'"
|
||||
:class="['tab-pill', { active: activeTab === 'groups' }]"
|
||||
@click="activeTab = 'groups'"
|
||||
>
|
||||
团队 <span v-if="teamsCount !== null" class="count">{{ teamsCount }}</span>
|
||||
</button>
|
||||
<button :class="['tab-pill', { active: activeTab === 'roles' }]" @click="activeTab = 'roles'">
|
||||
角色 <span class="tab-badge" v-if="rolesCount !== null">{{ rolesCount }}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'roles'"
|
||||
:class="['tab-pill', { active: activeTab === 'roles' }]"
|
||||
@click="activeTab = 'roles'"
|
||||
>
|
||||
角色 <span v-if="rolesCount !== null" class="count">{{ rolesCount }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<span class="toolbar-grow"></span>
|
||||
|
||||
<label class="search members-search">
|
||||
<span class="search-icon" aria-hidden="true" v-html="navIconSvg('search', 16, 'icon-sm')"></span>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="搜索成员…"
|
||||
aria-label="搜索成员"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content-area">
|
||||
<UserList v-if="activeTab === 'users'" ref="userListRef" :search-query="searchQuery" @changed="loadCounts" />
|
||||
<GroupList v-else-if="activeTab === 'groups'" :search-query="searchQuery" @changed="loadCounts" />
|
||||
<RoleList v-else-if="activeTab === 'roles'" :search-query="searchQuery" @changed="loadCounts" />
|
||||
<UserList
|
||||
v-if="activeTab === 'users'"
|
||||
ref="userListRef"
|
||||
:search-query="searchQuery"
|
||||
@changed="onChanged"
|
||||
/>
|
||||
<GroupList v-else-if="activeTab === 'groups'" :search-query="searchQuery" @changed="onChanged" />
|
||||
<RoleList v-else-if="activeTab === 'roles'" :search-query="searchQuery" @changed="onChanged" />
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* .page-inner (global, inside .rp-shell) provides the centred column and the
|
||||
shell provides the 40/48px padding — this class is only kept as the semantic
|
||||
hook the existing view tests mount against. */
|
||||
.human-agent-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 11px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: var(--radius-btn);
|
||||
min-width: 220px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.invite-btn {
|
||||
display: inline-flex;
|
||||
/* ── Toolbar ──────────────────────────────────────────────────────────── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
white-space: nowrap;
|
||||
transition: filter var(--transition-fast);
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.invite-btn:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* Pill tabs */
|
||||
/* The tabs sit in a toolbar here, not under a rule of their own */
|
||||
.tab-pills {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
align-self: flex-start;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* Class hooks kept for the existing tests — styling comes from global .tabs */
|
||||
.tab-pill {
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.toolbar-grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Scoped one level deeper than the shell's `.rp-shell .search` so the page's
|
||||
search keeps its own width (and stays visible on phones, see below). */
|
||||
.toolbar .members-search {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 9px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tab-pill:hover:not(.active) {
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.tab-pill.active {
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--o06);
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.tab-pill.active .tab-badge {
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-actions {
|
||||
/* ── Metric strip accents ─────────────────────────────────────────────── */
|
||||
.stat-value.is-alert,
|
||||
.num.is-alert {
|
||||
color: var(--vermilion);
|
||||
}
|
||||
|
||||
.num.is-alert {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.toolbar .members-search {
|
||||
width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The shell hides .search at 900px (its topbar search); the page's own
|
||||
search has to stay usable, so it opts back in. */
|
||||
@media (max-width: 900px) {
|
||||
.toolbar {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.toolbar .members-search {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.search-box {
|
||||
flex: 1;
|
||||
|
||||
.toolbar-grow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -22,13 +22,12 @@ import { userService } from '@/services/user'
|
||||
const organizationId = computed(() => userService.getCurrentUser()?.organization_id ?? '')
|
||||
|
||||
const description =
|
||||
'这里汇集 AI 智能体学习的所有页面。你可以查看并编辑已提取的内容、添加自己的页面,' +
|
||||
'并决定哪些内容保留在知识库中。'
|
||||
'AI 依据这里的内容作答。导入网页、PDF 或手写页面;改动会在索引后立即生效。'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<div class="knowledge-view">
|
||||
<div class="page-inner knowledge-view">
|
||||
<KnowledgeExplorer
|
||||
v-if="organizationId"
|
||||
mode="org"
|
||||
@@ -38,14 +37,28 @@ const description =
|
||||
:organization-id="organizationId"
|
||||
:show-plan-meters="true"
|
||||
/>
|
||||
|
||||
<div v-else class="knowledge-empty">
|
||||
<div class="eyebrow">Knowledge · 智能</div>
|
||||
<h1 class="title">知识库</h1>
|
||||
<p class="lede">
|
||||
无法加载知识库:当前账号还没有归属的组织。请确认登录信息,或联系管理员把你加入组织。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-view {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 60px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.knowledge-empty {
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.knowledge-empty .lede {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,7 @@ import { ref, onMounted, computed, onUnmounted, watch, nextTick } from 'vue'
|
||||
import {
|
||||
isValidEmail} from '../types/widget'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import BrandMark from '../components/common/BrandMark.vue'
|
||||
import AskAiPanel from './AskAiPanel.vue'
|
||||
import NewChatConfirm from './NewChatConfirm.vue'
|
||||
import { resolveOrbStyle } from '../utils/orb'
|
||||
@@ -370,7 +371,7 @@ const {
|
||||
headerBorderStyles,
|
||||
photoUrl,
|
||||
shadowStyle
|
||||
} = useWidgetStyles(customization)
|
||||
} = useWidgetStyles(customization, agentName)
|
||||
|
||||
// File input ref - must be defined before useWidgetFiles
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
@@ -1624,7 +1625,7 @@ const isAskAnythingStyle = computed(() => {
|
||||
return customization.value.chat_style === 'ASK_ANYTHING' || isAuroraStyle.value
|
||||
})
|
||||
|
||||
// Use the generated aurora orb when the user explicitly picked "orb", or as the Aurora
|
||||
// Use the paper-craft avatar when the user explicitly picked "orb", or as the Aurora
|
||||
// fallback when no profile photo is set. A selected profile picture always takes precedence.
|
||||
const orbMeta = computed(() => customization.value.customization_metadata as Record<string, unknown> | undefined)
|
||||
const useOrbAvatar = computed(() => {
|
||||
@@ -1911,12 +1912,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
该聊天 Widget 尚未配置,请联系网站管理员开启聊天支持。
|
||||
</p>
|
||||
<div class="widget-unavailable-footer">
|
||||
<svg class="replypier-logo-small" width="14" height="14" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo-small" :size="20" />
|
||||
<a class="rp-powered-link" href="https://replypier.site" target="_blank" rel="noopener"><span class="rp-powered-prefix">由 </span><strong class="rp-brand">ReplyPier</strong><span class="rp-powered-prefix"> 提供支持</span></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2078,12 +2074,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
|
||||
<!-- Powered by footer for welcome message -->
|
||||
<div class="powered-by-welcome" :style="messageNameStyles">
|
||||
<svg class="replypier-logo" width="16" height="16" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo" :size="20" />
|
||||
<a class="rp-powered-link" href="https://replypier.site" target="_blank" rel="noopener"><span class="rp-powered-prefix">由 </span><strong class="rp-brand">ReplyPier</strong><span class="rp-powered-prefix"> 提供支持</span></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2110,12 +2101,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
</div>
|
||||
<!-- Powered by footer for landing page -->
|
||||
<div class="powered-by-landing" :style="messageNameStyles">
|
||||
<svg class="replypier-logo" width="16" height="16" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo" :size="20" />
|
||||
<a class="rp-powered-link" href="https://replypier.site" target="_blank" rel="noopener"><span class="rp-powered-prefix">由 </span><strong class="rp-brand">ReplyPier</strong><span class="rp-powered-prefix"> 提供支持</span></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2273,12 +2259,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
</div>
|
||||
<!-- Powered by footer for form -->
|
||||
<div class="powered-by-landing" :style="messageNameStyles">
|
||||
<svg class="replypier-logo" width="16" height="16" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo" :size="20" />
|
||||
<a class="rp-powered-link" href="https://replypier.site" target="_blank" rel="noopener"><span class="rp-powered-prefix">由 </span><strong class="rp-brand">ReplyPier</strong><span class="rp-powered-prefix"> 提供支持</span></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2975,12 +2956,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
|
||||
<!-- Powered by footer -->
|
||||
<div class="powered-by" :style="messageNameStyles">
|
||||
<svg class="replypier-logo" width="16" height="16" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H41A16 16 0 0 1 57 19V41A16 16 0 0 1 41 57H9A6 6 0 0 1 3 51V19A16 16 0 0 1 19 3Z" fill="#C9F24E"/>
|
||||
<circle cx="19.7" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="30" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
<circle cx="40.3" cy="30" r="4.3" fill="#0B0C10"/>
|
||||
</svg>
|
||||
<BrandMark class="replypier-logo" :size="20" />
|
||||
<a class="rp-powered-link" href="https://replypier.site" target="_blank" rel="noopener"><span class="rp-powered-prefix">由 </span><strong class="rp-brand">ReplyPier</strong><span class="rp-powered-prefix"> 提供支持</span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import paperBoatSvg from '../assets/logo.svg?raw';
|
||||
|
||||
// @ts-nocheck
|
||||
// TypeScript declarations for global variables
|
||||
/** @type {string} */
|
||||
@@ -1625,17 +1627,13 @@ window.replypierConfig;
|
||||
// Footer
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'replypier-error-footer';
|
||||
const logoSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
logoSvg.setAttribute('width', '14');
|
||||
logoSvg.setAttribute('height', '14');
|
||||
logoSvg.setAttribute('viewBox', '0 0 60 60');
|
||||
logoSvg.setAttribute('fill', 'none');
|
||||
const logoPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
logoPath.setAttribute('d', 'M18 12 H42 A6 6 0 0 1 48 18 V42 A6 6 0 0 1 42 48 H18 A6 6 0 0 1 12 42 V18 A6 6 0 0 1 18 12 Z M17 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z M26 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z M35 30 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 Z');
|
||||
logoPath.setAttribute('fill-rule', 'evenodd');
|
||||
logoPath.setAttribute('fill', 'currentColor');
|
||||
logoPath.setAttribute('opacity', '0.6');
|
||||
logoSvg.appendChild(logoPath);
|
||||
const logoSvg = document.createElement('span');
|
||||
// Trusted bundled vector: the same source used by the dashboard and widget.
|
||||
logoSvg.innerHTML = paperBoatSvg;
|
||||
const mark = logoSvg.querySelector('svg');
|
||||
mark.setAttribute('width', '20');
|
||||
mark.setAttribute('height', '16');
|
||||
mark.setAttribute('aria-hidden', 'true');
|
||||
const footerText = document.createElement('a');
|
||||
footerText.href = 'https://replypier.site';
|
||||
footerText.target = '_blank';
|
||||
|
||||
@@ -93,8 +93,8 @@ export default defineConfig(({ command, mode }) => {
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
background_color: THEME_COLORS.dark,
|
||||
theme_color: THEME_COLORS.dark,
|
||||
background_color: THEME_COLORS.light,
|
||||
theme_color: THEME_COLORS.light,
|
||||
icons: [
|
||||
{ src: '/pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/pwa-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||
|
||||