You've already forked agentic-coding-workflow
Web优化 2
This commit is contained in:
@@ -548,6 +548,7 @@ func (h *Handler) sessionWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
conn, err := ws.Accept(w, r, &ws.AcceptOptions{
|
||||
InsecureSkipVerify: false,
|
||||
Subprotocols: []string{"acp-v1"},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -100,16 +100,26 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
|
||||
// because RFC 6750 specifies the literal "Bearer" scheme name and being
|
||||
// strict here keeps the parsing surface small.
|
||||
//
|
||||
// As a fallback, ?token= query parameter is accepted for endpoints that
|
||||
// cannot send custom headers (notably browser-native WebSocket connections).
|
||||
// Callers must keep token lifetimes short so the URL-borne token is not a
|
||||
// long-lived secret should it be logged by intermediaries.
|
||||
// For browser-native WebSocket connections, custom HTTP headers cannot be
|
||||
// sent during the handshake. Two fallbacks are supported, in order of
|
||||
// preference:
|
||||
// 1. Sec-WebSocket-Protocol: the client sends a subprotocol entry
|
||||
// "acw-token.<tok>". The token is extracted from the header, which is
|
||||
// not logged by proxies/servers as readily as a URL query parameter.
|
||||
// 2. ?token= query parameter (legacy): kept for backward compatibility.
|
||||
func extractBearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if strings.HasPrefix(h, prefix) {
|
||||
return strings.TrimSpace(h[len(prefix):])
|
||||
}
|
||||
const wsTokenPrefix = "acw-token."
|
||||
for _, p := range strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if strings.HasPrefix(p, wsTokenPrefix) {
|
||||
return p[len(wsTokenPrefix):]
|
||||
}
|
||||
}
|
||||
if q := r.URL.Query().Get("token"); q != "" {
|
||||
return q
|
||||
}
|
||||
|
||||
@@ -77,3 +77,18 @@ func TestAuth_BadTokenReturns401(t *testing.T) {
|
||||
h.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
func TestAuth_WebSocketTokenFromSubprotocol(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
mw := Auth(fakeAuth{user: uid}, AuthOptions{})
|
||||
h := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
got, ok := UserIDFromContext(r.Context())
|
||||
require.True(t, ok)
|
||||
require.Equal(t, uid, got)
|
||||
}))
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Sec-WebSocket-Protocol", "acp-v1, acw-token.ws-tok")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
+3
-3
@@ -12,7 +12,8 @@
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier -w .",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"analyze": "vite-bundle-visualizer"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -34,16 +35,15 @@
|
||||
"@types/node": "^25.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/test-utils": "^2.4.9",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-vue": "^10.9.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"postcss": "^8.5.12",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.1",
|
||||
"vite": "^8.0.10",
|
||||
"vite-bundle-visualizer": "^1.2.1",
|
||||
"vitest": "^4.1.5",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"vue-tsc": "^3.2.7"
|
||||
|
||||
Generated
+150
-77
@@ -48,9 +48,6 @@ importers:
|
||||
'@vue/test-utils':
|
||||
specifier: ^2.4.9
|
||||
version: 2.4.9(@vue/compiler-dom@3.5.33)(@vue/server-renderer@3.5.33(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3))
|
||||
autoprefixer:
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0(postcss@8.5.12)
|
||||
eslint:
|
||||
specifier: ^10.2.1
|
||||
version: 10.2.1(jiti@2.6.1)
|
||||
@@ -60,9 +57,6 @@ importers:
|
||||
happy-dom:
|
||||
specifier: ^20.9.0
|
||||
version: 20.9.0
|
||||
postcss:
|
||||
specifier: ^8.5.12
|
||||
version: 8.5.12
|
||||
prettier:
|
||||
specifier: ^3.8.3
|
||||
version: 3.8.3
|
||||
@@ -78,6 +72,9 @@ importers:
|
||||
vite:
|
||||
specifier: ^8.0.10
|
||||
version: 8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3)
|
||||
vite-bundle-visualizer:
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
vitest:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3))
|
||||
@@ -701,13 +698,6 @@ packages:
|
||||
async-validator@4.2.5:
|
||||
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
|
||||
|
||||
autoprefixer@10.5.0:
|
||||
resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
@@ -715,11 +705,6 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
baseline-browser-mapping@2.10.24:
|
||||
resolution: {integrity: sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
birpc@2.9.0:
|
||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
||||
|
||||
@@ -733,13 +718,9 @@ packages:
|
||||
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
browserslist@4.28.2:
|
||||
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
caniuse-lite@1.0.30001791:
|
||||
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
|
||||
cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
@@ -749,6 +730,10 @@ packages:
|
||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -814,6 +799,10 @@ packages:
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
define-lazy-prop@2.0.0:
|
||||
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -826,9 +815,6 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
electron-to-chromium@1.5.344:
|
||||
resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
@@ -967,14 +953,15 @@ packages:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
fraction.js@5.3.4:
|
||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
glob-parent@6.0.2:
|
||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -1006,6 +993,13 @@ packages:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
import-from-esm@1.3.4:
|
||||
resolution: {integrity: sha512-7EyUlPFC0HOlBDpUFGfYstsU7XHxZJKAAMzCT8wZ0hMW7b+hG51LIKTDcsgtz8Pu6YC0HqRVbX+rVUtsGMUKvg==}
|
||||
engines: {node: '>=16.20'}
|
||||
|
||||
import-meta-resolve@4.2.0:
|
||||
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
|
||||
|
||||
imurmurhash@0.1.4:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
@@ -1013,6 +1007,11 @@ packages:
|
||||
ini@1.3.8:
|
||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||
|
||||
is-docker@2.2.1:
|
||||
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1029,6 +1028,10 @@ packages:
|
||||
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
is-wsl@2.2.0:
|
||||
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
@@ -1220,9 +1223,6 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
node-releases@2.0.38:
|
||||
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
|
||||
|
||||
nopt@7.2.1:
|
||||
resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
@@ -1234,6 +1234,10 @@ packages:
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
open@8.4.2:
|
||||
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1299,9 +1303,6 @@ packages:
|
||||
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
postcss@8.5.12:
|
||||
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1333,6 +1334,10 @@ packages:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
@@ -1341,6 +1346,19 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
rollup-plugin-visualizer@5.14.0:
|
||||
resolution: {integrity: sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
rolldown: 1.x
|
||||
rollup: 2.x || 3.x || 4.x
|
||||
peerDependenciesMeta:
|
||||
rolldown:
|
||||
optional: true
|
||||
rollup:
|
||||
optional: true
|
||||
|
||||
scule@1.3.0:
|
||||
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
|
||||
|
||||
@@ -1375,6 +1393,10 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map@0.7.6:
|
||||
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
speakingurl@14.0.1:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1427,6 +1449,10 @@ packages:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tmp@0.2.7:
|
||||
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
treemate@0.3.11:
|
||||
resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==}
|
||||
|
||||
@@ -1472,12 +1498,6 @@ packages:
|
||||
resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
update-browserslist-db@1.2.3:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
@@ -1489,6 +1509,11 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.0.11
|
||||
|
||||
vite-bundle-visualizer@1.2.1:
|
||||
resolution: {integrity: sha512-cwz/Pg6+95YbgIDp+RPwEToc4TKxfsFWSG/tsl2DSZd9YZicUag1tQXjJ5xcL7ydvEoaC2FOZeaXOU60t9BRXw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
hasBin: true
|
||||
|
||||
vite@8.0.10:
|
||||
resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1669,11 +1694,23 @@ packages:
|
||||
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yaml@2.8.3:
|
||||
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2296,21 +2333,10 @@ snapshots:
|
||||
|
||||
async-validator@4.2.5: {}
|
||||
|
||||
autoprefixer@10.5.0(postcss@8.5.12):
|
||||
dependencies:
|
||||
browserslist: 4.28.2
|
||||
caniuse-lite: 1.0.30001791
|
||||
fraction.js: 5.3.4
|
||||
picocolors: 1.1.1
|
||||
postcss: 8.5.12
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
baseline-browser-mapping@2.10.24: {}
|
||||
|
||||
birpc@2.9.0: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
@@ -2323,15 +2349,7 @@ snapshots:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
browserslist@4.28.2:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.24
|
||||
caniuse-lite: 1.0.30001791
|
||||
electron-to-chromium: 1.5.344
|
||||
node-releases: 2.0.38
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.2)
|
||||
|
||||
caniuse-lite@1.0.30001791: {}
|
||||
cac@6.7.14: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
@@ -2339,6 +2357,12 @@ snapshots:
|
||||
dependencies:
|
||||
readdirp: 5.0.0
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 7.0.0
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -2391,6 +2415,8 @@ snapshots:
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
define-lazy-prop@2.0.0: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
@@ -2402,8 +2428,6 @@ snapshots:
|
||||
minimatch: 9.0.9
|
||||
semver: 7.7.4
|
||||
|
||||
electron-to-chromium@1.5.344: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
@@ -2545,11 +2569,11 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
glob-parent@6.0.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -2585,10 +2609,21 @@ snapshots:
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
import-from-esm@1.3.4:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
import-meta-resolve: 4.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
import-meta-resolve@4.2.0: {}
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
is-docker@2.2.1: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
@@ -2599,6 +2634,10 @@ snapshots:
|
||||
|
||||
is-what@5.5.0: {}
|
||||
|
||||
is-wsl@2.2.0:
|
||||
dependencies:
|
||||
is-docker: 2.2.1
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jackspeak@3.4.3:
|
||||
@@ -2775,8 +2814,6 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
node-releases@2.0.38: {}
|
||||
|
||||
nopt@7.2.1:
|
||||
dependencies:
|
||||
abbrev: 2.0.0
|
||||
@@ -2787,6 +2824,12 @@ snapshots:
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
open@8.4.2:
|
||||
dependencies:
|
||||
define-lazy-prop: 2.0.0
|
||||
is-docker: 2.2.1
|
||||
is-wsl: 2.2.0
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -2851,8 +2894,6 @@ snapshots:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss-value-parser@4.2.0: {}
|
||||
|
||||
postcss@8.5.12:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
@@ -2873,6 +2914,8 @@ snapshots:
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rolldown@1.0.0-rc.17:
|
||||
@@ -2896,6 +2939,13 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
|
||||
|
||||
rollup-plugin-visualizer@5.14.0:
|
||||
dependencies:
|
||||
open: 8.4.2
|
||||
picomatch: 4.0.4
|
||||
source-map: 0.7.6
|
||||
yargs: 17.7.2
|
||||
|
||||
scule@1.3.0: {}
|
||||
|
||||
seemly@0.3.10: {}
|
||||
@@ -2916,6 +2966,8 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map@0.7.6: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
@@ -2961,6 +3013,8 @@ snapshots:
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
tmp@0.2.7: {}
|
||||
|
||||
treemate@0.3.11: {}
|
||||
|
||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||
@@ -3004,12 +3058,6 @@ snapshots:
|
||||
picomatch: 4.0.4
|
||||
webpack-virtual-modules: 0.6.2
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
||||
dependencies:
|
||||
browserslist: 4.28.2
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
uri-js@4.4.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
@@ -3021,6 +3069,17 @@ snapshots:
|
||||
evtd: 0.2.4
|
||||
vue: 3.5.33(typescript@6.0.3)
|
||||
|
||||
vite-bundle-visualizer@1.2.1:
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
import-from-esm: 1.3.4
|
||||
rollup-plugin-visualizer: 5.14.0
|
||||
tmp: 0.2.7
|
||||
transitivePeerDependencies:
|
||||
- rolldown
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.3):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
@@ -3165,6 +3224,20 @@ snapshots:
|
||||
|
||||
xml-name-validator@4.0.0: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yaml@2.8.3: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
escalade: 3.2.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
string-width: 4.2.3
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
+11
-12
@@ -146,10 +146,8 @@ const enc = (v: string) => encodeURIComponent(v)
|
||||
|
||||
export const acpApi = {
|
||||
agentKinds: {
|
||||
list: () =>
|
||||
request<(AgentKindAdmin | AgentKindPublic)[]>('/api/v1/acp/agent-kinds'),
|
||||
get: (id: string) =>
|
||||
request<AgentKindAdmin>(`/api/v1/acp/agent-kinds/${enc(id)}`),
|
||||
list: () => request<(AgentKindAdmin | AgentKindPublic)[]>('/api/v1/acp/agent-kinds'),
|
||||
get: (id: string) => request<AgentKindAdmin>(`/api/v1/acp/agent-kinds/${enc(id)}`),
|
||||
create: (req: CreateAgentKindReq) =>
|
||||
request<AgentKindAdmin>('/api/v1/acp/agent-kinds', { method: 'POST', body: req }),
|
||||
update: (id: string, req: UpdateAgentKindReq) =>
|
||||
@@ -180,8 +178,7 @@ export const acpApi = {
|
||||
const qs = q.toString()
|
||||
return request<AcpSession[]>('/api/v1/acp/sessions' + (qs ? '?' + qs : ''))
|
||||
},
|
||||
get: (id: string) =>
|
||||
request<AcpSession>(`/api/v1/acp/sessions/${enc(id)}`),
|
||||
get: (id: string) => request<AcpSession>(`/api/v1/acp/sessions/${enc(id)}`),
|
||||
create: (req: CreateSessionReq) =>
|
||||
request<AcpSession>('/api/v1/acp/sessions', { method: 'POST', body: req }),
|
||||
terminate: (id: string) =>
|
||||
@@ -205,13 +202,15 @@ export const acpApi = {
|
||||
// openSessionWS opens a native WebSocket to /sessions/{id}/ws.
|
||||
//
|
||||
// Auth: browser WebSocket cannot send custom headers, so the token is sent
|
||||
// via the ?token= query parameter. The backend auth middleware accepts it
|
||||
// as a fallback to Authorization. Keep token lifetimes short.
|
||||
// via the Sec-WebSocket-Protocol handshake as "acw-token.<token>". This is
|
||||
// safer than a URL query parameter (which may be logged by proxies). The
|
||||
// application subprotocol "acp-v1" is also advertised and selected by the
|
||||
// server when supported.
|
||||
export function openSessionWS(sessionId: string, since: number, token: string): WebSocket {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const host = window.location.host
|
||||
const url =
|
||||
`${proto}://${host}/api/v1/acp/sessions/${enc(sessionId)}` +
|
||||
`/ws?since=${since}&token=${enc(token)}`
|
||||
return new WebSocket(url)
|
||||
const url = `${proto}://${host}/api/v1/acp/sessions/${enc(sessionId)}/ws?since=${since}`
|
||||
const protocols = ['acp-v1']
|
||||
if (token) protocols.push(`acw-token.${token}`)
|
||||
return new WebSocket(url, protocols)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { NInput, NButton, NSelect, NSpin } from 'naive-ui'
|
||||
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
||||
import { acpApi, type AgentConfigFile, type AgentClientType } from '@/api/acp'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const props = defineProps<{
|
||||
kindId: string
|
||||
@@ -22,6 +23,7 @@ const props = defineProps<{
|
||||
const files = ref<AgentConfigFile[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const confirm = useConfirm()
|
||||
|
||||
// ===== 客户端规范配置文件 =====
|
||||
|
||||
@@ -270,7 +272,8 @@ async function upsert(relPath: string, content: string) {
|
||||
}
|
||||
|
||||
async function removeFile(relPath: string) {
|
||||
if (!confirm(`删除配置文件 ${relPath}?`)) return
|
||||
const ok = await confirm(`删除配置文件 ${relPath}?`)
|
||||
if (!ok) return
|
||||
error.value = ''
|
||||
try {
|
||||
await acpApi.configFiles.remove(props.kindId, relPath)
|
||||
@@ -299,9 +302,7 @@ watch(() => props.clientType, loadForm)
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">
|
||||
客户端配置文件
|
||||
</h2>
|
||||
<h2 class="text-lg font-semibold">客户端配置文件</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
配置存数据库,session 启动前物化到受管目录并通过
|
||||
<code v-if="clientType === 'claude_code'">CLAUDE_CONFIG_DIR</code>
|
||||
@@ -312,22 +313,18 @@ watch(() => props.clientType, loadForm)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="text-sm text-red-600"
|
||||
>
|
||||
<p v-if="error" class="text-sm text-red-600">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<NSpin :show="loading">
|
||||
<!-- 常用配置表单(已知客户端类型) -->
|
||||
<div
|
||||
v-if="canonicalPath"
|
||||
class="border rounded p-4 space-y-3"
|
||||
>
|
||||
<div v-if="canonicalPath" class="border rounded p-4 space-y-3">
|
||||
<div class="text-sm font-medium">
|
||||
常用配置
|
||||
<span class="text-xs text-gray-500 font-normal">(写入 {{ canonicalPath }},其余字段保留)</span>
|
||||
<span class="text-xs text-gray-500 font-normal"
|
||||
>(写入 {{ canonicalPath }},其余字段保留)</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Model</label>
|
||||
@@ -345,10 +342,7 @@ watch(() => props.clientType, loadForm)
|
||||
<template v-if="clientType === 'claude_code'">
|
||||
<div>
|
||||
<label class="block text-sm mb-1">permissions.defaultMode</label>
|
||||
<NInput
|
||||
v-model:value="form.defaultMode"
|
||||
placeholder="acceptEdits"
|
||||
/>
|
||||
<NInput v-model:value="form.defaultMode" placeholder="acceptEdits" />
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
@@ -392,34 +386,17 @@ watch(() => props.clientType, loadForm)
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">sandbox_mode</label>
|
||||
<NSelect
|
||||
v-model:value="form.sandboxMode"
|
||||
:options="sandboxModeOptions"
|
||||
clearable
|
||||
/>
|
||||
<NSelect v-model:value="form.sandboxMode" :options="sandboxModeOptions" clearable />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="saveForm"
|
||||
>
|
||||
保存常用配置
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" @click="saveForm"> 保存常用配置 </NButton>
|
||||
</div>
|
||||
|
||||
<!-- 高级模式:文件列表 + 原始编辑 -->
|
||||
<div class="border rounded p-4 space-y-3 mt-4">
|
||||
<div class="text-sm font-medium">
|
||||
全部配置文件(高级模式)
|
||||
</div>
|
||||
<div
|
||||
v-if="files.length === 0"
|
||||
class="text-sm text-gray-500"
|
||||
>
|
||||
暂无配置文件
|
||||
</div>
|
||||
<div class="text-sm font-medium">全部配置文件(高级模式)</div>
|
||||
<div v-if="files.length === 0" class="text-sm text-gray-500">暂无配置文件</div>
|
||||
<div
|
||||
v-for="f in files"
|
||||
:key="f.rel_path"
|
||||
@@ -428,31 +405,15 @@ watch(() => props.clientType, loadForm)
|
||||
<code>{{ f.rel_path }}</code>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-xs text-gray-400">{{ f.updated_at }}</span>
|
||||
<NButton
|
||||
size="tiny"
|
||||
@click="startEdit(f)"
|
||||
>
|
||||
编辑
|
||||
</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
@click="removeFile(f.rel_path)"
|
||||
>
|
||||
<NButton size="tiny" @click="startEdit(f)"> 编辑 </NButton>
|
||||
<NButton size="tiny" quaternary type="error" @click="removeFile(f.rel_path)">
|
||||
删除
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<NButton
|
||||
size="small"
|
||||
dashed
|
||||
@click="startNew()"
|
||||
>
|
||||
+ 新建文件
|
||||
</NButton>
|
||||
<NButton size="small" dashed @click="startNew()"> + 新建文件 </NButton>
|
||||
<NButton
|
||||
v-for="t in templates.filter((x) => !files.some((f) => f.rel_path === x.rel_path))"
|
||||
:key="t.rel_path"
|
||||
@@ -464,16 +425,12 @@ watch(() => props.clientType, loadForm)
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="editingIsNew || editingPath !== null"
|
||||
class="space-y-2 pt-2"
|
||||
>
|
||||
<div v-if="editingIsNew || editingPath !== null" class="space-y-2 pt-2">
|
||||
<div v-if="editingIsNew">
|
||||
<label class="block text-sm mb-1">文件路径(相对受管 home,如 .claude/settings.json)</label>
|
||||
<NInput
|
||||
v-model:value="newRelPath"
|
||||
placeholder=".claude/settings.json"
|
||||
/>
|
||||
<label class="block text-sm mb-1"
|
||||
>文件路径(相对受管 home,如 .claude/settings.json)</label
|
||||
>
|
||||
<NInput v-model:value="newRelPath" placeholder=".claude/settings.json" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<code class="text-sm">{{ editingPath }}</code>
|
||||
@@ -484,27 +441,12 @@ watch(() => props.clientType, loadForm)
|
||||
class="font-mono"
|
||||
:autosize="{ minRows: 8, maxRows: 24 }"
|
||||
/>
|
||||
<p
|
||||
v-if="syntaxError"
|
||||
class="text-xs text-red-600"
|
||||
>
|
||||
语法错误:{{ syntaxError }}
|
||||
</p>
|
||||
<p v-if="syntaxError" class="text-xs text-red-600">语法错误:{{ syntaxError }}</p>
|
||||
<div class="flex gap-2">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!!syntaxError"
|
||||
@click="saveEdit"
|
||||
>
|
||||
<NButton size="small" type="primary" :disabled="!!syntaxError" @click="saveEdit">
|
||||
保存
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
取消
|
||||
</NButton>
|
||||
<NButton size="small" @click="cancelEdit"> 取消 </NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ class MockWebSocket {
|
||||
}
|
||||
|
||||
url: string
|
||||
protocols: string[] | undefined
|
||||
readyState = 0
|
||||
onopen: ((ev: unknown) => void) | null = null
|
||||
onmessage: ((ev: { data: string }) => void) | null = null
|
||||
@@ -24,8 +25,9 @@ class MockWebSocket {
|
||||
onclose: ((ev: unknown) => void) | null = null
|
||||
sent: string[] = []
|
||||
|
||||
constructor(url: string) {
|
||||
constructor(url: string, protocols?: string[]) {
|
||||
this.url = url
|
||||
this.protocols = protocols
|
||||
MockWebSocket.instances.push(this)
|
||||
queueMicrotask(() => {
|
||||
this.readyState = 1
|
||||
@@ -66,7 +68,8 @@ describe('useAcpStream', () => {
|
||||
const ws = MockWebSocket.last()
|
||||
expect(ws.url).toContain('/sessions/sess-1/ws')
|
||||
expect(ws.url).toContain('since=0')
|
||||
expect(ws.url).toContain('token=jwt-token')
|
||||
expect(ws.url).not.toContain('token=')
|
||||
expect(ws.protocols).toEqual(['acp-v1', 'acw-token.jwt-token'])
|
||||
expect(stream.status.value).toBe('streaming')
|
||||
stream.close()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
export function useAlert() {
|
||||
let message: ReturnType<typeof useMessage> | undefined
|
||||
try {
|
||||
message = useMessage()
|
||||
} catch {
|
||||
// No outer <n-message-provider />, fall back to window.alert in browser.
|
||||
}
|
||||
|
||||
return (content: string) => {
|
||||
if (message) {
|
||||
message.error(content)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
window.alert(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useDialog } from 'naive-ui'
|
||||
|
||||
export function useConfirm() {
|
||||
let dialog: ReturnType<typeof useDialog> | undefined
|
||||
try {
|
||||
dialog = useDialog()
|
||||
} catch {
|
||||
// No outer <n-dialog-provider />, fall back to window.confirm in browser.
|
||||
}
|
||||
|
||||
return (content: string, title = '确认') => {
|
||||
if (!dialog) {
|
||||
if (typeof window !== 'undefined') {
|
||||
return Promise.resolve(window.confirm(content))
|
||||
}
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return new Promise<boolean>((resolve) => {
|
||||
dialog!.warning({
|
||||
title,
|
||||
content,
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => resolve(true),
|
||||
onNegativeClick: () => resolve(false),
|
||||
onClose: () => resolve(false),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import type {
|
||||
CommitBody,
|
||||
} from '@/api/types'
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 1500
|
||||
const DEFAULT_POLL_MAX_MS = 5 * 60 * 1000
|
||||
|
||||
export const useWorkspacesStore = defineStore('workspaces', () => {
|
||||
const list = ref<Workspace[]>([])
|
||||
const current = ref<Workspace | null>(null)
|
||||
@@ -75,8 +78,8 @@ export const useWorkspacesStore = defineStore('workspaces', () => {
|
||||
*/
|
||||
async function pollUntilSettled(
|
||||
wsID: string,
|
||||
intervalMs = 1500,
|
||||
maxMs = 5 * 60 * 1000,
|
||||
intervalMs = DEFAULT_POLL_INTERVAL_MS,
|
||||
maxMs = DEFAULT_POLL_MAX_MS,
|
||||
): Promise<Workspace> {
|
||||
const start = Date.now()
|
||||
let lastStatus: Workspace['sync_status'] | 'unknown' = 'unknown'
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
+22
-43
@@ -2,44 +2,25 @@
|
||||
<AppShell>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold">
|
||||
欢迎,{{ auth.user?.display_name }}
|
||||
</h2>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="router.push({ name: 'project-list' })"
|
||||
>
|
||||
新建 / 管理项目
|
||||
</NButton>
|
||||
<h2 class="text-xl font-semibold">欢迎,{{ auth.user?.display_name }}</h2>
|
||||
<NButton type="primary" @click="goToProjects"> 新建 / 管理项目 </NButton>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<NGrid
|
||||
:cols="3"
|
||||
:x-gap="12"
|
||||
>
|
||||
<NGrid :cols="3" :x-gap="12">
|
||||
<NGi>
|
||||
<NCard>
|
||||
<NStatistic
|
||||
label="活跃项目"
|
||||
:value="activeCount"
|
||||
/>
|
||||
<NStatistic label="活跃项目" :value="activeCount" />
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<NCard>
|
||||
<NStatistic
|
||||
label="归档项目"
|
||||
:value="archivedCount"
|
||||
/>
|
||||
<NStatistic label="归档项目" :value="archivedCount" />
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<NCard>
|
||||
<NStatistic
|
||||
label="分配给我的待办议题"
|
||||
:value="myIssues.length"
|
||||
/>
|
||||
<NStatistic label="分配给我的待办议题" :value="myIssues.length" />
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
@@ -47,16 +28,10 @@
|
||||
<!-- 分配给我的议题 -->
|
||||
<NCard title="分配给我的议题(未关闭)">
|
||||
<NSpin :show="loading">
|
||||
<div
|
||||
v-if="myIssues.length === 0"
|
||||
class="text-sm text-gray-400 py-2"
|
||||
>
|
||||
<div v-if="myIssues.length === 0" class="text-sm text-gray-400 py-2">
|
||||
暂无分配给你的未关闭议题。
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
class="divide-y"
|
||||
>
|
||||
<ul v-else class="divide-y">
|
||||
<li
|
||||
v-for="row in myIssues"
|
||||
:key="row.issue.id"
|
||||
@@ -64,7 +39,9 @@
|
||||
@click="goIssue(row)"
|
||||
>
|
||||
<span class="text-sm">
|
||||
<span class="font-mono text-gray-500 mr-2">{{ row.projectSlug }}#{{ row.issue.number }}</span>
|
||||
<span class="font-mono text-gray-500 mr-2"
|
||||
>{{ row.projectSlug }}#{{ row.issue.number }}</span
|
||||
>
|
||||
{{ row.issue.title }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ row.projectName }}</span>
|
||||
@@ -76,21 +53,15 @@
|
||||
<!-- 最近项目 -->
|
||||
<NCard title="项目">
|
||||
<NSpin :show="loading">
|
||||
<div
|
||||
v-if="activeProjects.length === 0"
|
||||
class="text-sm text-gray-400 py-2"
|
||||
>
|
||||
<div v-if="activeProjects.length === 0" class="text-sm text-gray-400 py-2">
|
||||
还没有项目,去创建一个吧。
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
class="divide-y"
|
||||
>
|
||||
<ul v-else class="divide-y">
|
||||
<li
|
||||
v-for="p in activeProjects"
|
||||
:key="p.id"
|
||||
class="py-2 flex items-center justify-between cursor-pointer hover:bg-gray-50 px-1"
|
||||
@click="router.push({ name: 'project-home', params: { slug: p.slug } })"
|
||||
@click="goToProject(p)"
|
||||
>
|
||||
<span class="text-sm font-medium">{{ p.name }}</span>
|
||||
<span class="font-mono text-xs text-gray-400">{{ p.slug }}</span>
|
||||
@@ -163,4 +134,12 @@ function goIssue(row: MyIssueRow) {
|
||||
params: { slug: row.projectSlug, number: row.issue.number },
|
||||
})
|
||||
}
|
||||
|
||||
function goToProjects() {
|
||||
router.push({ name: 'project-list' })
|
||||
}
|
||||
|
||||
function goToProject(p: Project) {
|
||||
router.push({ name: 'project-home', params: { slug: p.slug } })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useAcpStore } from '@/stores/acp'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
||||
|
||||
const store = useAcpStore()
|
||||
const confirm = useConfirm()
|
||||
const alert = useAlert()
|
||||
|
||||
onMounted(() => {
|
||||
// all=true → 管理员视图,列出所有用户的会话。
|
||||
@@ -12,7 +18,8 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
async function terminate(id: string) {
|
||||
if (!confirm('Terminate this session?')) return
|
||||
const ok = await confirm('Terminate this session?')
|
||||
if (!ok) return
|
||||
try {
|
||||
await store.terminateSession(id)
|
||||
} catch (e) {
|
||||
@@ -25,60 +32,28 @@ async function terminate(id: string) {
|
||||
<AppShell>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">
|
||||
ACP 会话管理
|
||||
</h2>
|
||||
<h2 class="text-lg font-semibold">ACP 会话管理</h2>
|
||||
<span class="text-sm text-gray-500">全局管理员视图(所有用户)</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.loading"
|
||||
class="text-gray-500"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="store.error"
|
||||
class="text-red-600"
|
||||
>
|
||||
<div v-if="store.loading" class="text-gray-500">Loading...</div>
|
||||
<div v-else-if="store.error" class="text-red-600">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="store.sessions.length === 0"
|
||||
class="text-gray-500"
|
||||
>
|
||||
No sessions.
|
||||
</div>
|
||||
<div v-else-if="store.sessions.length === 0" class="text-gray-500">No sessions.</div>
|
||||
|
||||
<table
|
||||
v-else
|
||||
class="w-full text-sm"
|
||||
>
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead class="text-left">
|
||||
<tr class="border-b">
|
||||
<th class="px-3 py-2">
|
||||
User
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
Branch
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
Started
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right">
|
||||
Actions
|
||||
</th>
|
||||
<th class="px-3 py-2">User</th>
|
||||
<th class="px-3 py-2">Branch</th>
|
||||
<th class="px-3 py-2">Status</th>
|
||||
<th class="px-3 py-2">Started</th>
|
||||
<th class="px-3 py-2 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="s in store.sessions"
|
||||
:key="s.id"
|
||||
class="border-t hover:bg-gray-50"
|
||||
>
|
||||
<tr v-for="s in store.sessions" :key="s.id" class="border-t hover:bg-gray-50">
|
||||
<td class="px-3 py-2 font-mono text-xs">
|
||||
{{ s.user_id }}
|
||||
</td>
|
||||
@@ -89,16 +64,17 @@ async function terminate(id: string) {
|
||||
<SessionStatusBadge :status="s.status" />
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-500">
|
||||
{{ new Date(s.started_at).toLocaleString() }}
|
||||
{{ formatDateTime(s.started_at) }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right space-x-2">
|
||||
<button
|
||||
<NButton
|
||||
v-if="s.status === 'starting' || s.status === 'running'"
|
||||
class="text-red-600 hover:underline"
|
||||
text
|
||||
type="error"
|
||||
@click="terminate(s.id)"
|
||||
>
|
||||
Terminate
|
||||
</button>
|
||||
</NButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useAcpStore } from '@/stores/acp'
|
||||
import { useAcpStream, type UseAcpStreamReturn } from '@/composables/useAcpStream'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { acpApi, type AcpEvent, type PermissionRequest } from '@/api/acp'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { workspacesApi } from '@/api/workspaces'
|
||||
@@ -15,6 +17,7 @@ import CommitDialog from '@/views/workspaces/components/CommitDialog.vue'
|
||||
const route = useRoute()
|
||||
const store = useAcpStore()
|
||||
const auth = useAuthStore()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const sessionId = computed(() => route.params.sessionId as string)
|
||||
|
||||
@@ -119,7 +122,8 @@ function onCancel() {
|
||||
}
|
||||
|
||||
async function terminate() {
|
||||
if (!confirm('Terminate this session?')) return
|
||||
const ok = await confirm('Terminate this session?')
|
||||
if (!ok) return
|
||||
try {
|
||||
await store.terminateSession(sessionId.value)
|
||||
} catch (e) {
|
||||
@@ -166,42 +170,31 @@ async function openCommit() {
|
||||
<div class="space-x-3 text-sm">
|
||||
<span class="font-mono">{{ store.currentSession?.branch }}</span>
|
||||
<span class="text-gray-500">·</span>
|
||||
<span
|
||||
class="text-xs text-gray-500 font-mono truncate max-w-md inline-block align-middle"
|
||||
>
|
||||
<span class="text-xs text-gray-500 font-mono truncate max-w-md inline-block align-middle">
|
||||
{{ store.currentSession?.cwd_path }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionStatusBadge :status="sessionStatus" />
|
||||
<button
|
||||
class="px-2 py-1 text-xs rounded border text-blue-600 hover:bg-blue-50"
|
||||
@click="openCommit"
|
||||
>
|
||||
提交代码
|
||||
</button>
|
||||
<button
|
||||
<NButton text type="primary" size="small" @click="openCommit"> 提交代码 </NButton>
|
||||
<NButton
|
||||
v-if="sessionStatus === 'starting' || sessionStatus === 'running'"
|
||||
class="px-2 py-1 text-xs rounded border text-red-600 hover:bg-red-50"
|
||||
text
|
||||
type="error"
|
||||
size="small"
|
||||
@click="terminate"
|
||||
>
|
||||
Terminate
|
||||
</button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="errorMsg"
|
||||
class="px-4 py-2 text-sm text-red-600 bg-red-50"
|
||||
>
|
||||
<p v-if="errorMsg" class="px-4 py-2 text-sm text-red-600 bg-red-50">
|
||||
{{ errorMsg }}
|
||||
</p>
|
||||
|
||||
<!-- WS client_error 帧提示 -->
|
||||
<p
|
||||
v-if="streamError"
|
||||
class="px-4 py-2 text-sm text-red-700 bg-red-100"
|
||||
>
|
||||
<p v-if="streamError" class="px-4 py-2 text-sm text-red-700 bg-red-100">
|
||||
连接错误:{{ streamError }}
|
||||
</p>
|
||||
|
||||
@@ -214,19 +207,9 @@ async function openCommit() {
|
||||
/>
|
||||
|
||||
<!-- Events stream -->
|
||||
<div
|
||||
ref="eventsContainer"
|
||||
class="flex-1 overflow-y-auto px-4 py-3 space-y-1"
|
||||
>
|
||||
<AgentEventCard
|
||||
v-for="ev in events"
|
||||
:key="ev.id"
|
||||
:event="ev"
|
||||
/>
|
||||
<div
|
||||
v-if="events.length === 0"
|
||||
class="text-center text-gray-400 py-8"
|
||||
>
|
||||
<div ref="eventsContainer" class="flex-1 overflow-y-auto px-4 py-3 space-y-1">
|
||||
<AgentEventCard v-for="ev in events" :key="ev.id" :event="ev" />
|
||||
<div v-if="events.length === 0" class="text-center text-gray-400 py-8">
|
||||
Waiting for agent...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useAcpStore } from '@/stores/acp'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAcpStore()
|
||||
const confirm = useConfirm()
|
||||
const alert = useAlert()
|
||||
|
||||
onMounted(() => {
|
||||
void store.listSessions(false)
|
||||
})
|
||||
|
||||
function open(id: string) {
|
||||
router.push(
|
||||
`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/${id}`,
|
||||
)
|
||||
router.push(`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/${id}`)
|
||||
}
|
||||
|
||||
function newSession() {
|
||||
router.push(
|
||||
`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/new`,
|
||||
)
|
||||
router.push(`/p/${route.params.slug}/w/${route.params.wsSlug}/acp-sessions/new`)
|
||||
}
|
||||
|
||||
async function terminate(id: string) {
|
||||
if (!confirm('Terminate this session?')) return
|
||||
const ok = await confirm('Terminate this session?')
|
||||
if (!ok) return
|
||||
try {
|
||||
await store.terminateSession(id)
|
||||
} catch (e) {
|
||||
@@ -37,62 +40,29 @@ async function terminate(id: string) {
|
||||
<template>
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">
|
||||
ACP Sessions
|
||||
</h1>
|
||||
<button
|
||||
class="px-3 py-1 rounded text-sm font-medium bg-blue-600 text-white hover:bg-blue-700"
|
||||
@click="newSession"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
<h1 class="text-2xl font-semibold">ACP Sessions</h1>
|
||||
<NButton type="primary" @click="newSession"> + New </NButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.loading"
|
||||
class="text-gray-500"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="store.error"
|
||||
class="text-red-600"
|
||||
>
|
||||
<div v-if="store.loading" class="text-gray-500">Loading...</div>
|
||||
<div v-else-if="store.error" class="text-red-600">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="store.sessions.length === 0"
|
||||
class="text-gray-500"
|
||||
>
|
||||
<div v-else-if="store.sessions.length === 0" class="text-gray-500">
|
||||
No sessions yet. Click "+ New" to create one.
|
||||
</div>
|
||||
|
||||
<table
|
||||
v-else
|
||||
class="w-full text-sm"
|
||||
>
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead class="text-left">
|
||||
<tr class="border-b">
|
||||
<th class="px-3 py-2">
|
||||
Branch
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
Started
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right">
|
||||
Actions
|
||||
</th>
|
||||
<th class="px-3 py-2">Branch</th>
|
||||
<th class="px-3 py-2">Status</th>
|
||||
<th class="px-3 py-2">Started</th>
|
||||
<th class="px-3 py-2 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="s in store.sessions"
|
||||
:key="s.id"
|
||||
class="border-t hover:bg-gray-50"
|
||||
>
|
||||
<tr v-for="s in store.sessions" :key="s.id" class="border-t hover:bg-gray-50">
|
||||
<td class="px-3 py-2 font-mono text-xs">
|
||||
{{ s.branch }}
|
||||
</td>
|
||||
@@ -100,22 +70,18 @@ async function terminate(id: string) {
|
||||
<SessionStatusBadge :status="s.status" />
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-500">
|
||||
{{ new Date(s.started_at).toLocaleString() }}
|
||||
{{ formatDateTime(s.started_at) }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right space-x-2">
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="open(s.id)"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
<NButton text type="primary" @click="open(s.id)"> Open </NButton>
|
||||
<NButton
|
||||
v-if="s.status === 'starting' || s.status === 'running'"
|
||||
class="text-red-600 hover:underline"
|
||||
text
|
||||
type="error"
|
||||
@click="terminate(s.id)"
|
||||
>
|
||||
Terminate
|
||||
</button>
|
||||
</NButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -4,9 +4,13 @@ import { useRouter } from 'vue-router'
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useAcpStore } from '@/stores/acp'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAcpStore()
|
||||
const confirm = useConfirm()
|
||||
const alert = useAlert()
|
||||
|
||||
onMounted(() => store.listAgentKinds(true))
|
||||
|
||||
@@ -14,8 +18,17 @@ async function toggleEnabled(id: string, enabled: boolean) {
|
||||
await store.updateAgentKind(id, { enabled })
|
||||
}
|
||||
|
||||
function goNew() {
|
||||
router.push('/admin/acp/agent-kinds/new')
|
||||
}
|
||||
|
||||
function goEdit(id: string) {
|
||||
router.push(`/admin/acp/agent-kinds/${id}`)
|
||||
}
|
||||
|
||||
async function remove(id: string, name: string) {
|
||||
if (!confirm(`Delete agent kind "${name}"?`)) return
|
||||
const ok = await confirm(`Delete agent kind "${name}"?`)
|
||||
if (!ok) return
|
||||
try {
|
||||
await store.deleteAgentKind(id)
|
||||
} catch (e) {
|
||||
@@ -29,51 +42,24 @@ async function remove(id: string, name: string) {
|
||||
<NSpin :show="store.loading">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">
|
||||
ACP Agent Kinds
|
||||
</h1>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="router.push('/admin/acp/agent-kinds/new')"
|
||||
>
|
||||
+ New
|
||||
</NButton>
|
||||
<h1 class="text-2xl font-semibold">ACP Agent Kinds</h1>
|
||||
<NButton type="primary" @click="goNew"> + New </NButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="store.error"
|
||||
class="text-red-600"
|
||||
>
|
||||
<div v-if="store.error" class="text-red-600">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
<table
|
||||
v-else
|
||||
class="w-full text-sm"
|
||||
>
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead class="text-left">
|
||||
<tr>
|
||||
<th class="px-3 py-2">
|
||||
Name
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
Display
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
Binary
|
||||
</th>
|
||||
<th class="px-3 py-2">
|
||||
Enabled
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right">
|
||||
Actions
|
||||
</th>
|
||||
<th class="px-3 py-2">Name</th>
|
||||
<th class="px-3 py-2">Display</th>
|
||||
<th class="px-3 py-2">Binary</th>
|
||||
<th class="px-3 py-2">Enabled</th>
|
||||
<th class="px-3 py-2 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="k in store.agentKinds"
|
||||
:key="k.id"
|
||||
class="border-t"
|
||||
>
|
||||
<tr v-for="k in store.agentKinds" :key="k.id" class="border-t">
|
||||
<td class="px-3 py-2 font-mono">
|
||||
{{ k.name }}
|
||||
</td>
|
||||
@@ -88,23 +74,11 @@ async function remove(id: string, name: string) {
|
||||
type="checkbox"
|
||||
:checked="k.enabled"
|
||||
@change="toggleEnabled(k.id, !k.enabled)"
|
||||
>
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right space-x-2">
|
||||
<NButton
|
||||
text
|
||||
type="primary"
|
||||
@click="router.push(`/admin/acp/agent-kinds/${k.id}`)"
|
||||
>
|
||||
Edit
|
||||
</NButton>
|
||||
<NButton
|
||||
text
|
||||
type="error"
|
||||
@click="remove(k.id, k.name)"
|
||||
>
|
||||
Delete
|
||||
</NButton>
|
||||
<NButton text type="primary" @click="goEdit(k.id)"> Edit </NButton>
|
||||
<NButton text type="error" @click="remove(k.id, k.name)"> Delete </NButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -5,11 +5,17 @@ import AppShell from '@/layouts/AppShell.vue'
|
||||
import AgentKindForm from '@/components/acp/AgentKindForm.vue'
|
||||
import AgentConfigFilesPanel from '@/components/acp/AgentConfigFilesPanel.vue'
|
||||
import { useAcpStore } from '@/stores/acp'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
import type { AgentClientType, McpServerSpec, UpdateAgentKindReq } from '@/api/acp'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useAcpStore()
|
||||
const alert = useAlert()
|
||||
|
||||
function goBack() {
|
||||
router.push('/admin/acp/agent-kinds')
|
||||
}
|
||||
|
||||
const id = computed(() => (route.params.id as string | undefined) ?? null)
|
||||
const isEdit = computed(() => id.value !== null)
|
||||
@@ -79,7 +85,7 @@ async function submit() {
|
||||
mcp_servers: form.value.mcp_servers,
|
||||
})
|
||||
}
|
||||
router.push('/admin/acp/agent-kinds')
|
||||
goBack()
|
||||
} catch (e) {
|
||||
alert(`Save failed: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||
}
|
||||
@@ -97,21 +103,12 @@ async function submit() {
|
||||
:is-edit="isEdit"
|
||||
:existing-env-keys="existingEnvKeys"
|
||||
@submit="submit"
|
||||
@cancel="router.push('/admin/acp/agent-kinds')"
|
||||
/>
|
||||
<div
|
||||
v-if="isEdit && id"
|
||||
class="mt-8"
|
||||
>
|
||||
<AgentConfigFilesPanel
|
||||
:kind-id="id"
|
||||
:client-type="form.client_type"
|
||||
@cancel="goBack"
|
||||
/>
|
||||
<div v-if="isEdit && id" class="mt-8">
|
||||
<AgentConfigFilesPanel :kind-id="id" :client-type="form.client_type" />
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="text-xs text-gray-500 mt-6"
|
||||
>
|
||||
<p v-else class="text-xs text-gray-500 mt-6">
|
||||
保存后可在编辑页配置该客户端的配置文件(settings.json / config.toml 等)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
{{ adminMode ? '用量(全局)' : '我的用量' }}
|
||||
</h2>
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<NDatePicker
|
||||
v-model:value="range"
|
||||
type="daterange"
|
||||
clearable
|
||||
/>
|
||||
<NDatePicker v-model:value="range" type="daterange" clearable />
|
||||
<NSelect
|
||||
v-if="adminMode"
|
||||
v-model:value="groupBy"
|
||||
@@ -17,10 +13,7 @@
|
||||
style="width: 160px"
|
||||
/>
|
||||
</div>
|
||||
<NDataTable
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
/>
|
||||
<NDataTable :data="rows" :columns="columns" />
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -31,12 +24,9 @@ import { NDataTable, NDatePicker, NSelect } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import {
|
||||
llmAdminApi,
|
||||
type AdminUsageGroupBy,
|
||||
type AdminUsageItem,
|
||||
} from '@/api/llmAdmin'
|
||||
import { llmAdminApi, type AdminUsageGroupBy, type AdminUsageItem } from '@/api/llmAdmin'
|
||||
import { chatApi, type UsageDailyItem } from '@/api/chat'
|
||||
import { formatDate } from '@/utils/date'
|
||||
|
||||
interface Props {
|
||||
adminMode?: boolean
|
||||
@@ -69,17 +59,12 @@ const columns = computed<DataTableColumns<Row>>(() => {
|
||||
]
|
||||
if (props.adminMode) {
|
||||
const labelCol: DataTableColumns<Row>[number] = {
|
||||
title:
|
||||
groupBy.value === 'user'
|
||||
? '用户'
|
||||
: groupBy.value === 'model'
|
||||
? '模型'
|
||||
: '日期',
|
||||
title: groupBy.value === 'user' ? '用户' : groupBy.value === 'model' ? '模型' : '日期',
|
||||
key: 'key',
|
||||
render: (r) => {
|
||||
const k = (r as AdminUsageItem).key
|
||||
if (groupBy.value === 'day') {
|
||||
return new Date(k).toLocaleDateString('zh-CN')
|
||||
return formatDate(k)
|
||||
}
|
||||
return k
|
||||
},
|
||||
@@ -90,7 +75,7 @@ const columns = computed<DataTableColumns<Row>>(() => {
|
||||
{
|
||||
title: '日期',
|
||||
key: 'day',
|
||||
render: (r) => new Date((r as UsageDailyItem).day).toLocaleDateString('zh-CN'),
|
||||
render: (r) => formatDate((r as UsageDailyItem).day),
|
||||
},
|
||||
...tokenCols,
|
||||
]
|
||||
|
||||
@@ -3,13 +3,7 @@
|
||||
<div class="flex h-[calc(100vh-8rem)] -m-6">
|
||||
<!-- 左侧会话列表 -->
|
||||
<div class="w-72 border-r overflow-y-auto p-2 flex flex-col gap-1">
|
||||
<NButton
|
||||
block
|
||||
class="mb-2"
|
||||
@click="showCreate = true"
|
||||
>
|
||||
+ 新建对话
|
||||
</NButton>
|
||||
<NButton block class="mb-2" @click="showCreate = true"> + 新建对话 </NButton>
|
||||
<div
|
||||
v-for="c in chat.conversations"
|
||||
:key="c.id"
|
||||
@@ -27,10 +21,7 @@
|
||||
{{ formatTime(c.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<NPopconfirm
|
||||
:show-icon="false"
|
||||
@positive-click="chat.deleteConversation(c.id)"
|
||||
>
|
||||
<NPopconfirm :show-icon="false" @positive-click="chat.deleteConversation(c.id)">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
text
|
||||
@@ -43,15 +34,10 @@
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
<template #default>
|
||||
删除后无法恢复,确定删除此会话?
|
||||
</template>
|
||||
<template #default> 删除后无法恢复,确定删除此会话? </template>
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<div
|
||||
v-if="chat.conversations.length === 0"
|
||||
class="text-xs text-neutral-400 px-2"
|
||||
>
|
||||
<div v-if="chat.conversations.length === 0" class="text-xs text-neutral-400 px-2">
|
||||
暂无对话
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,30 +56,19 @@
|
||||
<div class="text-sm font-medium truncate flex-1">
|
||||
{{ chat.currentConversation.title || '(未命名)' }}
|
||||
</div>
|
||||
<NPopconfirm
|
||||
:show-icon="false"
|
||||
@positive-click="deleteCurrentConversation"
|
||||
>
|
||||
<NPopconfirm :show-icon="false" @positive-click="deleteCurrentConversation">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
text
|
||||
size="small"
|
||||
>
|
||||
<NButton text size="small">
|
||||
<template #icon>
|
||||
<NIcon><TrashOutline /></NIcon>
|
||||
</template>
|
||||
删除
|
||||
</NButton>
|
||||
</template>
|
||||
<template #default>
|
||||
删除后无法恢复,确定删除此会话?
|
||||
</template>
|
||||
<template #default> 删除后无法恢复,确定删除此会话? </template>
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<div
|
||||
ref="scrollEl"
|
||||
class="flex-1 overflow-y-auto p-4 space-y-3"
|
||||
>
|
||||
<div ref="scrollEl" class="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<MessageBubble
|
||||
v-for="m in chat.messages"
|
||||
:key="m.id"
|
||||
@@ -125,13 +100,7 @@
|
||||
>
|
||||
发送
|
||||
</NButton>
|
||||
<NButton
|
||||
v-else
|
||||
type="warning"
|
||||
@click="chat.cancelMessage"
|
||||
>
|
||||
中断
|
||||
</NButton>
|
||||
<NButton v-else type="warning" @click="chat.cancelMessage"> 中断 </NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -154,6 +123,7 @@ import { TrashOutline } from '@vicons/ionicons5'
|
||||
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
const MessageBubble = defineAsyncComponent(() => import('./components/MessageBubble.vue'))
|
||||
import AttachmentUploader from './components/AttachmentUploader.vue'
|
||||
const ConversationCreateModal = defineAsyncComponent(() => import('./ConversationCreateModal.vue'))
|
||||
@@ -271,6 +241,6 @@ async function deleteCurrentConversation() {
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
return formatDateTime(iso)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<template>
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<NUpload
|
||||
:custom-request="upload"
|
||||
:show-file-list="false"
|
||||
multiple
|
||||
>
|
||||
<NButton size="small">
|
||||
添加附件
|
||||
</NButton>
|
||||
<NUpload :custom-request="upload" :show-file-list="false" multiple>
|
||||
<NButton size="small"> 添加附件 </NButton>
|
||||
</NUpload>
|
||||
<div
|
||||
v-for="a in attachments"
|
||||
@@ -15,13 +9,7 @@
|
||||
class="text-xs px-2 py-1 bg-neutral-100 rounded flex items-center gap-1"
|
||||
>
|
||||
<span>{{ a.filename }} ({{ Math.round(a.size_bytes / 1024) }} KB)</span>
|
||||
<NButton
|
||||
text
|
||||
size="tiny"
|
||||
@click="remove(a.id)"
|
||||
>
|
||||
×
|
||||
</NButton>
|
||||
<NButton text size="tiny" @click="remove(a.id)"> × </NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,6 +29,9 @@ const emit = defineEmits<{ change: [ids: string[]] }>()
|
||||
const attachments = ref<Attachment[]>([])
|
||||
const msg = useMessage()
|
||||
|
||||
const MAX_ATTACHMENT_SIZE_MB = 20
|
||||
const MAX_ATTACHMENT_SIZE_BYTES = MAX_ATTACHMENT_SIZE_MB * 1024 * 1024
|
||||
|
||||
async function upload(opts: UploadCustomRequestOptions) {
|
||||
const f = opts.file.file
|
||||
if (!f) {
|
||||
@@ -62,8 +53,8 @@ async function upload(opts: UploadCustomRequestOptions) {
|
||||
opts.onError()
|
||||
return
|
||||
}
|
||||
if (f.size > 20 * 1024 * 1024) {
|
||||
msg.error('单文件不能超过 20 MB')
|
||||
if (f.size > MAX_ATTACHMENT_SIZE_BYTES) {
|
||||
msg.error(`单文件不能超过 ${MAX_ATTACHMENT_SIZE_MB} MB`)
|
||||
opts.onError()
|
||||
return
|
||||
}
|
||||
@@ -83,7 +74,11 @@ function remove(id: string) {
|
||||
|
||||
watch(
|
||||
() => attachments.value.map((a) => a.id),
|
||||
() => emit('change', attachments.value.map((a) => a.id)),
|
||||
() =>
|
||||
emit(
|
||||
'change',
|
||||
attachments.value.map((a) => a.id),
|
||||
),
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<template>
|
||||
<AppShell>
|
||||
<NSpin :show="loading">
|
||||
<div
|
||||
v-if="data"
|
||||
class="space-y-6"
|
||||
>
|
||||
<div v-if="data" class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -12,10 +9,7 @@
|
||||
<h1 class="text-2xl font-semibold">
|
||||
{{ data.requirement.title }}
|
||||
</h1>
|
||||
<NTag
|
||||
:type="data.requirement.status === 'open' ? 'success' : 'default'"
|
||||
size="small"
|
||||
>
|
||||
<NTag :type="data.requirement.status === 'open' ? 'success' : 'default'" size="small">
|
||||
{{ data.requirement.status === 'open' ? '开放' : '关闭' }}
|
||||
</NTag>
|
||||
</div>
|
||||
@@ -29,30 +23,16 @@
|
||||
>
|
||||
关闭需求
|
||||
</NButton>
|
||||
<NButton
|
||||
v-else
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="toggling"
|
||||
@click="onReopen"
|
||||
>
|
||||
<NButton v-else type="primary" size="small" :loading="toggling" @click="onReopen">
|
||||
重新开放
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
@click="router.back()"
|
||||
>
|
||||
返回
|
||||
</NButton>
|
||||
<NButton size="small" @click="router.back()"> 返回 </NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase flow bar(阶段跳转始终由用户确认触发) -->
|
||||
<div class="flex items-center gap-1">
|
||||
<template
|
||||
v-for="(phase, idx) in PHASES"
|
||||
:key="phase"
|
||||
>
|
||||
<template v-for="(phase, idx) in PHASES" :key="phase">
|
||||
<NButton
|
||||
:type="phase === data.requirement.phase ? 'primary' : 'default'"
|
||||
:secondary="phase !== data.requirement.phase"
|
||||
@@ -62,10 +42,7 @@
|
||||
>
|
||||
{{ PHASE_LABELS[phase] }}
|
||||
</NButton>
|
||||
<span
|
||||
v-if="idx < PHASES.length - 1"
|
||||
class="text-gray-300 text-xs"
|
||||
>›</span>
|
||||
<span v-if="idx < PHASES.length - 1" class="text-gray-300 text-xs">›</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -74,18 +51,10 @@
|
||||
<div class="w-[45%] shrink-0 space-y-6">
|
||||
<!-- Description -->
|
||||
<NCard title="描述">
|
||||
<p
|
||||
v-if="data.requirement.description"
|
||||
class="text-gray-700 whitespace-pre-wrap"
|
||||
>
|
||||
<p v-if="data.requirement.description" class="text-gray-700 whitespace-pre-wrap">
|
||||
{{ data.requirement.description }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-gray-400 text-sm"
|
||||
>
|
||||
暂无描述
|
||||
</p>
|
||||
<p v-else class="text-gray-400 text-sm">暂无描述</p>
|
||||
</NCard>
|
||||
|
||||
<!-- 当前阶段产物(仅 AI 对话三阶段显示,保留编辑功能) -->
|
||||
@@ -122,34 +91,22 @@
|
||||
|
||||
<!-- Linked Issues -->
|
||||
<NCard title="关联 Issue">
|
||||
<NList
|
||||
v-if="data.issues.length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="issue in data.issues"
|
||||
:key="issue.id"
|
||||
>
|
||||
<NList v-if="data.issues.length > 0" :show-divider="false">
|
||||
<NListItem v-for="issue in data.issues" :key="issue.id">
|
||||
<NThing>
|
||||
<template #header>
|
||||
<span class="font-mono text-xs text-gray-400 mr-2">#{{ issue.number }}</span>
|
||||
<span class="text-sm font-medium">{{ issue.title }}</span>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<NTag
|
||||
:type="issue.status === 'open' ? 'success' : 'default'"
|
||||
size="tiny"
|
||||
>
|
||||
<NTag :type="issue.status === 'open' ? 'success' : 'default'" size="tiny">
|
||||
{{ issue.status === 'open' ? '开放' : '关闭' }}
|
||||
</NTag>
|
||||
</template>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无关联 Issue"
|
||||
/>
|
||||
<NEmpty v-else description="暂无关联 Issue" />
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
@@ -172,19 +129,12 @@
|
||||
:slug="slug"
|
||||
:requirement="data.requirement"
|
||||
/>
|
||||
<RequirementSummaryPanel
|
||||
v-else
|
||||
:slug="slug"
|
||||
:number="number"
|
||||
/>
|
||||
<RequirementSummaryPanel v-else :slug="slug" :number="number" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
class="text-gray-400 text-sm py-8 text-center"
|
||||
>
|
||||
<div v-else-if="!loading" class="text-gray-400 text-sm py-8 text-center">
|
||||
需求不存在或无权访问。
|
||||
</div>
|
||||
</NSpin>
|
||||
@@ -195,17 +145,7 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useDialog } from 'naive-ui'
|
||||
import {
|
||||
NCard,
|
||||
NButton,
|
||||
NTag,
|
||||
NSpin,
|
||||
NSelect,
|
||||
NList,
|
||||
NListItem,
|
||||
NThing,
|
||||
NEmpty,
|
||||
} from 'naive-ui'
|
||||
import { NCard, NButton, NTag, NSpin, NSelect, NList, NListItem, NThing, NEmpty } from 'naive-ui'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { projectsApi } from '@/api/projects'
|
||||
import { useRequirementsStore } from '@/stores/requirements'
|
||||
@@ -295,8 +235,7 @@ function onArtifactSaved() {
|
||||
async function onWorkspaceChange(v: string | null) {
|
||||
if (!data.value) return
|
||||
await projectsApi.updateRequirement(slug, number, { workspace_id: v })
|
||||
// Re-fetch to keep data.requirement and data.issues consistent (mirrors onPhaseClick)
|
||||
data.value = await projectsApi.getRequirement(slug, number)
|
||||
data.value.requirement.workspace_id = v
|
||||
}
|
||||
|
||||
function onPhaseClick(target: Phase) {
|
||||
@@ -308,9 +247,8 @@ function onPhaseClick(target: Phase) {
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await store.changePhase(slug, number, target)
|
||||
// Refresh local data to reflect the change
|
||||
data.value = await projectsApi.getRequirement(slug, number)
|
||||
const updated = await store.changePhase(slug, number, target)
|
||||
data.value!.requirement = updated
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -319,7 +257,7 @@ async function onClose() {
|
||||
toggling.value = true
|
||||
try {
|
||||
await store.close(slug, number)
|
||||
data.value = await projectsApi.getRequirement(slug, number)
|
||||
data.value!.requirement.status = 'closed'
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
@@ -329,7 +267,7 @@ async function onReopen() {
|
||||
toggling.value = true
|
||||
try {
|
||||
await store.reopen(slug, number)
|
||||
data.value = await projectsApi.getRequirement(slug, number)
|
||||
data.value!.requirement.status = 'open'
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
|
||||
@@ -3,83 +3,48 @@
|
||||
<div class="flex gap-4">
|
||||
<!-- 版本列表 -->
|
||||
<div class="w-44 shrink-0">
|
||||
<NList
|
||||
v-if="artifacts.length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="a in artifacts"
|
||||
:key="a.id"
|
||||
>
|
||||
<NThing
|
||||
class="cursor-pointer"
|
||||
@click="selectedVersion = a.version"
|
||||
>
|
||||
<NList v-if="artifacts.length > 0" :show-divider="false">
|
||||
<NListItem v-for="a in artifacts" :key="a.id">
|
||||
<NThing class="cursor-pointer" @click="selectedVersion = a.version">
|
||||
<template #header>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="selectedVersion === a.version ? 'text-blue-600' : ''"
|
||||
>v{{ a.version }}</span>
|
||||
<NTag
|
||||
v-if="a.source_message_id"
|
||||
size="tiny"
|
||||
class="ml-1"
|
||||
>v{{ a.version }}</span
|
||||
>
|
||||
AI
|
||||
</NTag>
|
||||
<NTag v-if="a.source_message_id" size="tiny" class="ml-1"> AI </NTag>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ new Date(a.created_at).toLocaleDateString() }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="a.note"
|
||||
class="text-xs text-gray-500"
|
||||
>{{ a.note }}</span>
|
||||
<span v-if="a.note" class="text-xs text-gray-500">{{ a.note }}</span>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无产物版本"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else description="暂无产物版本" size="small" />
|
||||
</div>
|
||||
|
||||
<!-- 选中版本内容(markdown 渲染) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
v-if="selectedArtifact"
|
||||
class="prose prose-sm max-w-none"
|
||||
v-html="rendered"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="text-gray-400 text-sm"
|
||||
>
|
||||
选择左侧版本以查看内容
|
||||
</p>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div v-if="selectedArtifact" class="prose prose-sm max-w-none" v-html="rendered" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<p v-else class="text-gray-400 text-sm">选择左侧版本以查看内容</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动提交新版本 -->
|
||||
<NDivider />
|
||||
<div
|
||||
v-if="!readonly"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div v-if="!readonly" class="space-y-2">
|
||||
<NInput
|
||||
v-model:value="newContent"
|
||||
type="textarea"
|
||||
placeholder="产物内容(Markdown)"
|
||||
:autosize="{ minRows: 3, maxRows: 10 }"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="newNote"
|
||||
placeholder="备注(可选)"
|
||||
/>
|
||||
<NInput v-model:value="newNote" placeholder="备注(可选)" />
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
@@ -90,28 +55,13 @@
|
||||
提交新版本
|
||||
</NButton>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="text-gray-400 text-sm"
|
||||
>
|
||||
需求已关闭,无法提交新版本
|
||||
</p>
|
||||
<p v-else class="text-gray-400 text-sm">需求已关闭,无法提交新版本</p>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
NDivider,
|
||||
NEmpty,
|
||||
NInput,
|
||||
NList,
|
||||
NListItem,
|
||||
NTag,
|
||||
NThing,
|
||||
} from 'naive-ui'
|
||||
import { NButton, NCard, NDivider, NEmpty, NInput, NList, NListItem, NTag, NThing } from 'naive-ui'
|
||||
import type { Artifact, ArtifactPhase } from '@/api/projects'
|
||||
import { md } from '@/utils/markdown'
|
||||
import { useRequirementsStore } from '@/stores/requirements'
|
||||
|
||||
@@ -4,21 +4,10 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">关联本需求的 agent 会话</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<NButton
|
||||
size="small"
|
||||
:loading="loading"
|
||||
@click="refresh"
|
||||
>
|
||||
刷新
|
||||
</NButton>
|
||||
<NButton size="small" :loading="loading" @click="refresh"> 刷新 </NButton>
|
||||
<NTooltip :disabled="!!workspaceSlug">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!workspaceSlug"
|
||||
@click="goCreate"
|
||||
>
|
||||
<NButton size="small" type="primary" :disabled="!workspaceSlug" @click="goCreate">
|
||||
发起{{ phaseLabel }}会话
|
||||
</NButton>
|
||||
</template>
|
||||
@@ -27,18 +16,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NList
|
||||
v-if="sessions.length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
>
|
||||
<NThing
|
||||
class="cursor-pointer"
|
||||
@click="goDetail(s)"
|
||||
>
|
||||
<NList v-if="sessions.length > 0" :show-divider="false">
|
||||
<NListItem v-for="s in sessions" :key="s.id">
|
||||
<NThing class="cursor-pointer" @click="goDetail(s)">
|
||||
<template #header>
|
||||
<span class="font-mono text-sm">{{ s.branch }}</span>
|
||||
</template>
|
||||
@@ -46,17 +26,13 @@
|
||||
<SessionStatusBadge :status="s.status" />
|
||||
</template>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ new Date(s.started_at).toLocaleString('zh-CN', { hour12: false }) }}
|
||||
{{ formatDateTime(s.started_at) }}
|
||||
<template v-if="s.last_error"> · {{ s.last_error }}</template>
|
||||
</span>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无关联会话"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else description="暂无关联会话" size="small" />
|
||||
</div>
|
||||
</NCard>
|
||||
</template>
|
||||
@@ -68,6 +44,7 @@ import { NButton, NCard, NEmpty, NList, NListItem, NThing, NTooltip } from 'naiv
|
||||
|
||||
import { acpApi, type AcpSession } from '@/api/acp'
|
||||
import type { Requirement } from '@/api/projects'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
||||
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||
|
||||
@@ -82,9 +59,7 @@ const wsStore = useWorkspacesStore()
|
||||
const sessions = ref<AcpSession[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const phaseLabel = computed(() =>
|
||||
props.requirement.phase === 'reviewing' ? '验收' : '实施',
|
||||
)
|
||||
const phaseLabel = computed(() => (props.requirement.phase === 'reviewing' ? '验收' : '实施'))
|
||||
|
||||
// requirement.workspace_id → workspace slug(路由需要 wsSlug)。
|
||||
const workspaceSlug = computed(() => {
|
||||
|
||||
@@ -15,21 +15,11 @@
|
||||
:disabled="creating"
|
||||
@update:value="onSelectConversation"
|
||||
/>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
secondary
|
||||
@click="openCreateForm"
|
||||
>
|
||||
新建会话
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" secondary @click="openCreateForm"> 新建会话 </NButton>
|
||||
</div>
|
||||
|
||||
<!-- 新建会话表单 -->
|
||||
<div
|
||||
v-if="showCreateForm"
|
||||
class="space-y-2 mb-3 border rounded p-3"
|
||||
>
|
||||
<div v-if="showCreateForm" class="space-y-2 mb-3 border rounded p-3">
|
||||
<ModelSelector v-model:model-value="newModelId" />
|
||||
<NInput
|
||||
v-model:value="newSystemPrompt"
|
||||
@@ -44,12 +34,7 @@
|
||||
:autosize="{ minRows: 1, maxRows: 4 }"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<NButton
|
||||
size="small"
|
||||
@click="showCreateForm = false"
|
||||
>
|
||||
取消
|
||||
</NButton>
|
||||
<NButton size="small" @click="showCreateForm = false"> 取消 </NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
@@ -80,18 +65,12 @@
|
||||
@answer-question="onAnswerQuestion"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex-1 flex items-center justify-center text-neutral-400 text-sm"
|
||||
>
|
||||
<div v-else class="flex-1 flex items-center justify-center text-neutral-400 text-sm">
|
||||
选择历史会话,或新建会话开始与 AI 讨论本阶段内容
|
||||
</div>
|
||||
|
||||
<!-- 输入区 -->
|
||||
<div
|
||||
v-if="session.currentConversation.value"
|
||||
class="border-t pt-3 flex gap-2"
|
||||
>
|
||||
<div v-if="session.currentConversation.value" class="border-t pt-3 flex gap-2">
|
||||
<NInput
|
||||
v-model:value="draft"
|
||||
type="textarea"
|
||||
@@ -108,13 +87,7 @@
|
||||
>
|
||||
发送
|
||||
</NButton>
|
||||
<NButton
|
||||
v-else
|
||||
type="warning"
|
||||
@click="session.cancelMessage"
|
||||
>
|
||||
中断
|
||||
</NButton>
|
||||
<NButton v-else type="warning" @click="session.cancelMessage"> 中断 </NButton>
|
||||
</div>
|
||||
|
||||
<SaveArtifactModal
|
||||
@@ -134,6 +107,7 @@ import { NButton, NCard, NInput, NSelect } from 'naive-ui'
|
||||
|
||||
import { chatApi, type ChatMessage, type Conversation } from '@/api/chat'
|
||||
import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import { useConversationSession } from '@/composables/useConversationSession'
|
||||
import { buildRequirementSystemPrompt } from '@/constants/requirementPhasePrompts'
|
||||
import {
|
||||
@@ -181,7 +155,7 @@ const saveSource = ref<ChatMessage | null>(null)
|
||||
|
||||
const conversationOptions = computed(() =>
|
||||
conversations.value.map((c) => ({
|
||||
label: `${c.title || '(未命名)'} · ${new Date(c.updated_at).toLocaleString('zh-CN', { hour12: false })}`,
|
||||
label: `${c.title || '(未命名)'} · ${formatDateTime(c.updated_at)}`,
|
||||
value: c.id,
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<template>
|
||||
<NCard title="产物与会话历史">
|
||||
<NSpin :show="loading">
|
||||
<NCollapse
|
||||
v-if="hasAnyContent"
|
||||
:default-expanded-names="defaultExpandedNames"
|
||||
>
|
||||
<NCollapse v-if="hasAnyContent" :default-expanded-names="defaultExpandedNames">
|
||||
<!-- 产物:按阶段分组 -->
|
||||
<NCollapseItem
|
||||
v-for="phase in ARTIFACT_PHASES"
|
||||
@@ -15,81 +12,46 @@
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ PHASE_TITLE[phase] }}产物</span>
|
||||
<NTag
|
||||
size="tiny"
|
||||
:type="phase === currentPhase ? 'primary' : 'default'"
|
||||
>
|
||||
<NTag size="tiny" :type="phase === currentPhase ? 'primary' : 'default'">
|
||||
{{ artifactsByPhase[phase].length }}
|
||||
</NTag>
|
||||
<span
|
||||
v-if="phase === currentPhase"
|
||||
class="text-xs text-blue-500"
|
||||
>当前</span>
|
||||
<span v-if="phase === currentPhase" class="text-xs text-blue-500">当前</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NList
|
||||
v-if="artifactsByPhase[phase].length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="a in artifactsByPhase[phase]"
|
||||
:key="a.id"
|
||||
>
|
||||
<NThing
|
||||
class="cursor-pointer"
|
||||
@click="toggleArtifact(a)"
|
||||
>
|
||||
<NList v-if="artifactsByPhase[phase].length > 0" :show-divider="false">
|
||||
<NListItem v-for="a in artifactsByPhase[phase]" :key="a.id">
|
||||
<NThing class="cursor-pointer" @click="toggleArtifact(a)">
|
||||
<template #header>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="selectedArtifact?.id === a.id ? 'text-blue-600' : ''"
|
||||
>v{{ a.version }}</span>
|
||||
<NTag
|
||||
v-if="a.source_message_id"
|
||||
size="tiny"
|
||||
class="ml-1"
|
||||
>v{{ a.version }}</span
|
||||
>
|
||||
AI
|
||||
</NTag>
|
||||
<NTag v-if="a.source_message_id" size="tiny" class="ml-1"> AI </NTag>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ formatDate(a.created_at) }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="a.note"
|
||||
class="text-xs text-gray-500"
|
||||
>{{ a.note }}</span>
|
||||
<span v-if="a.note" class="text-xs text-gray-500">{{ a.note }}</span>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无产物"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else description="暂无产物" size="small" />
|
||||
|
||||
<!-- 选中产物内容预览 -->
|
||||
<div
|
||||
v-if="selectedArtifact && selectedArtifact.phase === phase"
|
||||
class="mt-3"
|
||||
>
|
||||
<div v-if="selectedArtifact && selectedArtifact.phase === phase" class="mt-3">
|
||||
<NDivider />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
class="prose prose-sm max-w-none"
|
||||
v-html="renderedArtifact"
|
||||
/>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div class="prose prose-sm max-w-none" v-html="renderedArtifact" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
|
||||
<!-- AI 对话 -->
|
||||
<NCollapseItem
|
||||
name="conversations"
|
||||
:disabled="conversations.length === 0"
|
||||
>
|
||||
<NCollapseItem name="conversations" :disabled="conversations.length === 0">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">AI 对话</span>
|
||||
@@ -99,18 +61,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NList
|
||||
v-if="conversations.length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="c in conversations"
|
||||
:key="c.id"
|
||||
>
|
||||
<NThing
|
||||
class="cursor-pointer"
|
||||
@click="goConversation(c)"
|
||||
>
|
||||
<NList v-if="conversations.length > 0" :show-divider="false">
|
||||
<NListItem v-for="c in conversations" :key="c.id">
|
||||
<NThing class="cursor-pointer" @click="goConversation(c)">
|
||||
<template #header>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
@@ -127,18 +80,11 @@
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无对话"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else description="暂无对话" size="small" />
|
||||
</NCollapseItem>
|
||||
|
||||
<!-- ACP 会话 -->
|
||||
<NCollapseItem
|
||||
name="acp-sessions"
|
||||
:disabled="acpSessions.length === 0"
|
||||
>
|
||||
<NCollapseItem name="acp-sessions" :disabled="acpSessions.length === 0">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">ACP 会话</span>
|
||||
@@ -148,14 +94,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<NList
|
||||
v-if="acpSessions.length > 0"
|
||||
:show-divider="false"
|
||||
>
|
||||
<NListItem
|
||||
v-for="s in acpSessions"
|
||||
:key="s.id"
|
||||
>
|
||||
<NList v-if="acpSessions.length > 0" :show-divider="false">
|
||||
<NListItem v-for="s in acpSessions" :key="s.id">
|
||||
<NThing
|
||||
:class="workspaceSlug ? 'cursor-pointer' : ''"
|
||||
@click="workspaceSlug ? goAcpSession(s) : undefined"
|
||||
@@ -173,19 +113,11 @@
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
<NEmpty
|
||||
v-else
|
||||
description="暂无会话"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else description="暂无会话" size="small" />
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
|
||||
<NEmpty
|
||||
v-else-if="!loading"
|
||||
description="暂无历史产物与会话"
|
||||
size="small"
|
||||
/>
|
||||
<NEmpty v-else-if="!loading" description="暂无历史产物与会话" size="small" />
|
||||
</NSpin>
|
||||
</NCard>
|
||||
</template>
|
||||
@@ -210,6 +142,7 @@ import { chatApi, type Conversation } from '@/api/chat'
|
||||
import { acpApi, type AcpSession } from '@/api/acp'
|
||||
import type { Artifact, ArtifactPhase, Phase } from '@/api/projects'
|
||||
import { md } from '@/utils/markdown'
|
||||
import { formatDate, formatDateTime } from '@/utils/date'
|
||||
import { useRequirementsStore } from '@/stores/requirements'
|
||||
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
||||
|
||||
@@ -258,7 +191,8 @@ const artifactsByPhase = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
const hasAnyContent = computed(() =>
|
||||
const hasAnyContent = computed(
|
||||
() =>
|
||||
artifacts.value.length > 0 || conversations.value.length > 0 || acpSessions.value.length > 0,
|
||||
)
|
||||
|
||||
@@ -313,14 +247,6 @@ function goAcpSession(s: AcpSession) {
|
||||
})
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
function formatDateTime(d: string) {
|
||||
return new Date(d).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -1,48 +1,28 @@
|
||||
<template>
|
||||
<NCard title="产物汇总">
|
||||
<NSpin :show="loading">
|
||||
<div
|
||||
v-if="grouped.length > 0"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div
|
||||
v-for="g in grouped"
|
||||
:key="g.phase"
|
||||
>
|
||||
<div v-if="grouped.length > 0" class="space-y-4">
|
||||
<div v-for="g in grouped" :key="g.phase">
|
||||
<h3 class="text-sm font-semibold mb-2">
|
||||
{{ PHASE_TITLE[g.phase] }}({{ g.items.length }} 个版本)
|
||||
</h3>
|
||||
<NList :show-divider="false">
|
||||
<NListItem
|
||||
v-for="a in g.items"
|
||||
:key="a.id"
|
||||
>
|
||||
<NThing
|
||||
class="cursor-pointer"
|
||||
@click="selected = selected?.id === a.id ? null : a"
|
||||
>
|
||||
<NListItem v-for="a in g.items" :key="a.id">
|
||||
<NThing class="cursor-pointer" @click="selected = selected?.id === a.id ? null : a">
|
||||
<template #header>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="selected?.id === a.id ? 'text-blue-600' : ''"
|
||||
>v{{ a.version }}</span>
|
||||
<NTag
|
||||
v-if="a.source_message_id"
|
||||
size="tiny"
|
||||
class="ml-1"
|
||||
>v{{ a.version }}</span
|
||||
>
|
||||
AI
|
||||
</NTag>
|
||||
<NTag v-if="a.source_message_id" size="tiny" class="ml-1"> AI </NTag>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ new Date(a.created_at).toLocaleString('zh-CN', { hour12: false }) }}
|
||||
{{ formatDateTime(a.created_at) }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="a.note"
|
||||
class="text-xs text-gray-500"
|
||||
>{{ a.note }}</span>
|
||||
<span v-if="a.note" class="text-xs text-gray-500">{{ a.note }}</span>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
</NList>
|
||||
@@ -50,17 +30,12 @@
|
||||
|
||||
<template v-if="selected">
|
||||
<NDivider />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
class="prose prose-sm max-w-none"
|
||||
v-html="rendered"
|
||||
/>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div class="prose prose-sm max-w-none" v-html="rendered" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</template>
|
||||
</div>
|
||||
<NEmpty
|
||||
v-else-if="!loading"
|
||||
description="暂无任何阶段产物"
|
||||
/>
|
||||
<NEmpty v-else-if="!loading" description="暂无任何阶段产物" />
|
||||
</NSpin>
|
||||
</NCard>
|
||||
</template>
|
||||
@@ -70,6 +45,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { NCard, NDivider, NEmpty, NList, NListItem, NSpin, NTag, NThing } from 'naive-ui'
|
||||
import type { Artifact, ArtifactPhase } from '@/api/projects'
|
||||
import { md } from '@/utils/markdown'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import { useRequirementsStore } from '@/stores/requirements'
|
||||
|
||||
const PHASE_TITLE: Record<ArtifactPhase, string> = {
|
||||
|
||||
@@ -8,10 +8,7 @@
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<NTabs type="segment">
|
||||
<NTabPane
|
||||
name="edit"
|
||||
tab="编辑"
|
||||
>
|
||||
<NTabPane name="edit" tab="编辑">
|
||||
<NInput
|
||||
v-model:value="content"
|
||||
type="textarea"
|
||||
@@ -19,31 +16,19 @@
|
||||
:autosize="{ minRows: 12, maxRows: 24 }"
|
||||
/>
|
||||
</NTabPane>
|
||||
<NTabPane
|
||||
name="preview"
|
||||
tab="预览"
|
||||
>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<NTabPane name="preview" tab="预览">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="prose prose-sm max-w-none max-h-[60vh] overflow-y-auto border rounded p-3"
|
||||
v-html="rendered"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
<NInput
|
||||
v-model:value="note"
|
||||
placeholder="版本备注(可选)"
|
||||
/>
|
||||
<NInput v-model:value="note" placeholder="版本备注(可选)" />
|
||||
<div class="flex justify-end gap-2">
|
||||
<NButton @click="emit('update:show', false)">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!content.trim()"
|
||||
@click="onSave"
|
||||
>
|
||||
<NButton @click="emit('update:show', false)"> 取消 </NButton>
|
||||
<NButton type="primary" :loading="saving" :disabled="!content.trim()" @click="onSave">
|
||||
保存为新版本
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
@@ -3,19 +3,8 @@
|
||||
<!-- 目标选择:main 或某个 worktree -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500">查看目标</span>
|
||||
<NSelect
|
||||
v-model:value="target"
|
||||
:options="targetOptions"
|
||||
size="small"
|
||||
class="w-64"
|
||||
/>
|
||||
<NButton
|
||||
size="small"
|
||||
:loading="loading"
|
||||
@click="refresh"
|
||||
>
|
||||
刷新
|
||||
</NButton>
|
||||
<NSelect v-model:value="target" :options="targetOptions" size="small" class="w-64" />
|
||||
<NButton size="small" :loading="loading" @click="refresh"> 刷新 </NButton>
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
@@ -35,6 +24,7 @@ import type { DataTableColumns } from 'naive-ui'
|
||||
|
||||
import { workspacesApi } from '@/api/workspaces'
|
||||
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
import type { CommitLog, Workspace } from '@/api/types'
|
||||
|
||||
const props = defineProps<{ ws: Workspace }>()
|
||||
@@ -52,10 +42,6 @@ const targetOptions = computed(() => {
|
||||
return opts
|
||||
})
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function shortHash(hash: string) {
|
||||
return hash.slice(0, 8)
|
||||
}
|
||||
@@ -67,11 +53,7 @@ const columns = computed<DataTableColumns<CommitLog>>(() => [
|
||||
width: 100,
|
||||
fixed: 'left',
|
||||
render(row: CommitLog) {
|
||||
return h(
|
||||
NTag,
|
||||
{ size: 'tiny', type: 'default' },
|
||||
{ default: () => shortHash(row.hash) },
|
||||
)
|
||||
return h(NTag, { size: 'tiny', type: 'default' }, { default: () => shortHash(row.hash) })
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -99,7 +81,7 @@ const columns = computed<DataTableColumns<CommitLog>>(() => [
|
||||
key: 'date',
|
||||
width: 160,
|
||||
render(row: CommitLog) {
|
||||
return h('span', { class: 'text-xs text-gray-500' }, formatDate(row.date))
|
||||
return h('span', { class: 'text-xs text-gray-500' }, formatDateTime(row.date))
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": ".",
|
||||
"noEmit": true,
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
cssCodeSplit: true,
|
||||
chunkSizeWarningLimit: 500,
|
||||
chunkSizeWarningLimit: 1000,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
|
||||
Reference in New Issue
Block a user