|
@@ -15,6 +15,7 @@ import { Agent, Message } from '../../../core/api/models';
|
|
|
import { ChatService } from '../../../core/chat.service';
|
|
import { ChatService } from '../../../core/chat.service';
|
|
|
import { DeviceService } from '../../../core/device.service';
|
|
import { DeviceService } from '../../../core/device.service';
|
|
|
import { GroupsService } from '../../../core/groups.service';
|
|
import { GroupsService } from '../../../core/groups.service';
|
|
|
|
|
+import { GroupRolesService } from '../../../core/group-roles.service';
|
|
|
import { LiveService } from '../../../core/live.service';
|
|
import { LiveService } from '../../../core/live.service';
|
|
|
import { LoopGuardService } from '../../../core/loop-guard.service';
|
|
import { LoopGuardService } from '../../../core/loop-guard.service';
|
|
|
import { MatrixClientService } from '../../../core/matrix-client.service';
|
|
import { MatrixClientService } from '../../../core/matrix-client.service';
|
|
@@ -93,7 +94,12 @@ import { SessionService } from '../../../core/session.service';
|
|
|
</span>
|
|
</span>
|
|
|
<div class="chat-title">
|
|
<div class="chat-title">
|
|
|
<strong>{{ currentRoom()?.name }}</strong>
|
|
<strong>{{ currentRoom()?.name }}</strong>
|
|
|
- <small class="ellipsis">{{ members().length }} 名数字生命</small>
|
|
|
|
|
|
|
+ <small class="ellipsis">
|
|
|
|
|
+ {{ members().length }} 名数字生命
|
|
|
|
|
+ @if (leaderName(); as ln) {
|
|
|
|
|
+ · 组长 {{ ln }}
|
|
|
|
|
+ }
|
|
|
|
|
+ </small>
|
|
|
</div>
|
|
</div>
|
|
|
<ui-button
|
|
<ui-button
|
|
|
variant="ghost"
|
|
variant="ghost"
|
|
@@ -204,6 +210,46 @@ import { SessionService } from '../../../core/session.service';
|
|
|
}
|
|
}
|
|
|
</section>
|
|
</section>
|
|
|
|
|
|
|
|
|
|
+ <!-- 角色编辑 -->
|
|
|
|
|
+ @if (editing()) {
|
|
|
|
|
+ <div class="role-editor" role="dialog" aria-modal="true" aria-label="编辑角色">
|
|
|
|
|
+ <div class="role-card">
|
|
|
|
|
+ <h3>编辑 {{ editing()!.name }} 的角色</h3>
|
|
|
|
|
+
|
|
|
|
|
+ <label class="role-field">
|
|
|
|
|
+ <span>职责</span>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ [value]="dutyDraft()"
|
|
|
|
|
+ (input)="dutyDraft.set($any($event.target).value)"
|
|
|
|
|
+ placeholder="如:前端 / 后端 / 设计"
|
|
|
|
|
+ maxlength="40"
|
|
|
|
|
+ />
|
|
|
|
|
+ </label>
|
|
|
|
|
+
|
|
|
|
|
+ <label class="role-check">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ [checked]="isLeader(editing()!.id)"
|
|
|
|
|
+ (change)="toggleLeader(editing()!.id)"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span>设为组长(会替换原组长)</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+
|
|
|
|
|
+ <p class="role-hint">
|
|
|
|
|
+ 组长负责汇总方案、对外沟通;组员负责各自部分。用户据此知道该找谁。
|
|
|
|
|
+ </p>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="role-actions">
|
|
|
|
|
+ <ui-button variant="ghost" size="sm" (click)="editing.set(null)">取消</ui-button>
|
|
|
|
|
+ <ui-button variant="primary" size="sm" [disabled]="savingRole()" (click)="saveRole()">
|
|
|
|
|
+ {{ savingRole() ? '保存中…' : '保存' }}
|
|
|
|
|
+ </ui-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
<!-- 成员栏 -->
|
|
<!-- 成员栏 -->
|
|
|
@if (roomId() && membersOpen()) {
|
|
@if (roomId() && membersOpen()) {
|
|
|
<aside class="col members-col" aria-label="群成员">
|
|
<aside class="col members-col" aria-label="群成员">
|
|
@@ -218,15 +264,34 @@ import { SessionService } from '../../../core/session.service';
|
|
|
/>
|
|
/>
|
|
|
</header>
|
|
</header>
|
|
|
<div class="member-list">
|
|
<div class="member-list">
|
|
|
- @for (a of members(); track a.id) {
|
|
|
|
|
- <div class="member-row">
|
|
|
|
|
|
|
+ @for (a of membersSorted(); track a.id) {
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="member-row"
|
|
|
|
|
+ [class.is-leader]="isLeader(a.id)"
|
|
|
|
|
+ (click)="openRoleEditor(a)"
|
|
|
|
|
+ [attr.aria-label]="'编辑 ' + a.name + ' 的角色'"
|
|
|
|
|
+ >
|
|
|
<ui-avatar [name]="a.name" [size]="'sm'" />
|
|
<ui-avatar [name]="a.name" [size]="'sm'" />
|
|
|
<span class="member-meta">
|
|
<span class="member-meta">
|
|
|
- <strong class="ellipsis">{{ a.name }}</strong>
|
|
|
|
|
- <small>{{ a.status === 'running' ? '执行中' : a.status === 'idle' ? '空闲' : a.status }}</small>
|
|
|
|
|
|
|
+ <span class="member-line">
|
|
|
|
|
+ <strong class="ellipsis">{{ a.name }}</strong>
|
|
|
|
|
+ @if (isLeader(a.id)) {
|
|
|
|
|
+ <span class="role-tag leader">组长</span>
|
|
|
|
|
+ } @else if (roles.dutyOf()(roomId()!, a.id)) {
|
|
|
|
|
+ <span class="role-tag">组员</span>
|
|
|
|
|
+ }
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <small class="ellipsis">
|
|
|
|
|
+ @if (roles.dutyOf()(roomId()!, a.id); as duty) {
|
|
|
|
|
+ {{ duty }}
|
|
|
|
|
+ } @else {
|
|
|
|
|
+ {{ a.status === 'running' ? '执行中' : a.status === 'idle' ? '空闲' : a.status }}
|
|
|
|
|
+ }
|
|
|
|
|
+ </small>
|
|
|
</span>
|
|
</span>
|
|
|
<span class="dot" [class.busy]="a.status === 'running'" aria-hidden="true"></span>
|
|
<span class="dot" [class.busy]="a.status === 'running'" aria-hidden="true"></span>
|
|
|
- </div>
|
|
|
|
|
|
|
+ </button>
|
|
|
}
|
|
}
|
|
|
@if (members().length === 0) {
|
|
@if (members().length === 0) {
|
|
|
<p class="empty-hint">该群的数字生命尚未绑定到平台账号。</p>
|
|
<p class="empty-hint">该群的数字生命尚未绑定到平台账号。</p>
|
|
@@ -482,6 +547,96 @@ import { SessionService } from '../../../core/session.service';
|
|
|
gap: var(--sp-2);
|
|
gap: var(--sp-2);
|
|
|
align-content: start;
|
|
align-content: start;
|
|
|
}
|
|
}
|
|
|
|
|
+ .member-line {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: var(--sp-1);
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ .role-tag {
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ padding: 0 5px;
|
|
|
|
|
+ border-radius: var(--r-tag);
|
|
|
|
|
+ background: var(--surface-hover);
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ font-size: var(--fs-11);
|
|
|
|
|
+ line-height: 16px;
|
|
|
|
|
+ }
|
|
|
|
|
+ .role-tag.leader {
|
|
|
|
|
+ background: var(--primary-subtle);
|
|
|
|
|
+ color: var(--primary);
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ }
|
|
|
|
|
+ /* 组长置顶并加左侧色条,用户一眼知道找谁 */
|
|
|
|
|
+ .role-editor {
|
|
|
|
|
+ position: fixed;
|
|
|
|
|
+ inset: 0;
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ place-items: center;
|
|
|
|
|
+ background: var(--overlay);
|
|
|
|
|
+ z-index: var(--z-modal);
|
|
|
|
|
+ padding: var(--sp-4);
|
|
|
|
|
+ }
|
|
|
|
|
+ .role-card {
|
|
|
|
|
+ width: min(400px, 92vw);
|
|
|
|
|
+ padding: var(--sp-5);
|
|
|
|
|
+ border-radius: var(--r-lg);
|
|
|
|
|
+ background: var(--surface);
|
|
|
|
|
+ box-shadow: var(--shadow-overlay);
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: var(--sp-3);
|
|
|
|
|
+ }
|
|
|
|
|
+ .role-card h3 {
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ font-size: var(--fs-17);
|
|
|
|
|
+ color: var(--ink);
|
|
|
|
|
+ }
|
|
|
|
|
+ .role-field {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: var(--sp-1);
|
|
|
|
|
+ font-size: var(--fs-13);
|
|
|
|
|
+ color: var(--ink-2);
|
|
|
|
|
+ }
|
|
|
|
|
+ .role-field input {
|
|
|
|
|
+ min-height: var(--touch-min);
|
|
|
|
|
+ padding: 0 var(--sp-3);
|
|
|
|
|
+ border: 1px solid var(--edge);
|
|
|
|
|
+ border-radius: var(--r-md);
|
|
|
|
|
+ background: var(--surface);
|
|
|
|
|
+ color: var(--ink);
|
|
|
|
|
+ font: inherit;
|
|
|
|
|
+ }
|
|
|
|
|
+ .role-check {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: var(--sp-2);
|
|
|
|
|
+ font-size: var(--fs-13);
|
|
|
|
|
+ color: var(--ink-2);
|
|
|
|
|
+ }
|
|
|
|
|
+ .role-hint {
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ font-size: var(--fs-12);
|
|
|
|
|
+ color: var(--muted);
|
|
|
|
|
+ line-height: 1.6;
|
|
|
|
|
+ }
|
|
|
|
|
+ .role-actions {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: flex-end;
|
|
|
|
|
+ gap: var(--sp-2);
|
|
|
|
|
+ }
|
|
|
|
|
+ .member-row {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ border: 0;
|
|
|
|
|
+ text-align: left;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ font: inherit;
|
|
|
|
|
+ }
|
|
|
|
|
+ .member-row:hover {
|
|
|
|
|
+ background: var(--surface-hover);
|
|
|
|
|
+ }
|
|
|
|
|
+ .member-row.is-leader {
|
|
|
|
|
+ border-left: 3px solid var(--primary);
|
|
|
|
|
+ }
|
|
|
.member-row {
|
|
.member-row {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
align-items: center;
|
|
align-items: center;
|
|
@@ -544,6 +699,7 @@ import { SessionService } from '../../../core/session.service';
|
|
|
})
|
|
})
|
|
|
export class DkGroupPage {
|
|
export class DkGroupPage {
|
|
|
readonly groups = inject(GroupsService);
|
|
readonly groups = inject(GroupsService);
|
|
|
|
|
+ readonly roles = inject(GroupRolesService);
|
|
|
readonly loop = inject(LoopGuardService);
|
|
readonly loop = inject(LoopGuardService);
|
|
|
private readonly live = inject(LiveService);
|
|
private readonly live = inject(LiveService);
|
|
|
private readonly chat = inject(ChatService);
|
|
private readonly chat = inject(ChatService);
|
|
@@ -628,6 +784,61 @@ export class DkGroupPage {
|
|
|
return mxId.replace(/^@/, '').split(':')[0];
|
|
return mxId.replace(/^@/, '').split(':')[0];
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ /** 正在编辑角色的成员 */
|
|
|
|
|
+ readonly editing = signal<Agent | null>(null);
|
|
|
|
|
+ readonly dutyDraft = signal('');
|
|
|
|
|
+ readonly savingRole = signal(false);
|
|
|
|
|
+
|
|
|
|
|
+ openRoleEditor(a: Agent): void {
|
|
|
|
|
+ this.editing.set(a);
|
|
|
|
|
+ const rid = this.roomId();
|
|
|
|
|
+ this.dutyDraft.set(rid ? this.roles.dutyOf()(rid, a.id) : '');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ toggleLeader(agentId: string): void {
|
|
|
|
|
+ const rid = this.roomId();
|
|
|
|
|
+ if (!rid) return;
|
|
|
|
|
+ // 再点一次取消组长(原组长置空)
|
|
|
|
|
+ const cur = this.isLeader(agentId);
|
|
|
|
|
+ void this.roles.save(rid, {
|
|
|
|
|
+ ...this.roles.rolesOf()(rid),
|
|
|
|
|
+ leader: cur ? undefined : agentId,
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async saveRole(): Promise<void> {
|
|
|
|
|
+ const rid = this.roomId();
|
|
|
|
|
+ const a = this.editing();
|
|
|
|
|
+ if (!rid || !a || this.savingRole()) return;
|
|
|
|
|
+ this.savingRole.set(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.roles.setDuty(rid, a.id, this.dutyDraft().trim());
|
|
|
|
|
+ this.editing.set(null);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.savingRole.set(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 组长置顶 */
|
|
|
|
|
+ readonly membersSorted = computed(() => {
|
|
|
|
|
+ const rid = this.roomId();
|
|
|
|
|
+ const list = this.members();
|
|
|
|
|
+ if (!rid) return list;
|
|
|
|
|
+ const leader = this.roles.leaderOf()(rid)?.id;
|
|
|
|
|
+ return [...list].sort((a, b) => Number(b.id === leader) - Number(a.id === leader));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ leaderName(): string {
|
|
|
|
|
+ const rid = this.roomId();
|
|
|
|
|
+ return rid ? (this.roles.leaderOf()(rid)?.name ?? '') : '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ isLeader(agentId: string): boolean {
|
|
|
|
|
+ const rid = this.roomId();
|
|
|
|
|
+ return rid ? this.roles.leaderOf()(rid)?.id === agentId : false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
memberNames(roomId: string): string {
|
|
memberNames(roomId: string): string {
|
|
|
const list = this.groups.membersOf()(roomId);
|
|
const list = this.groups.membersOf()(roomId);
|
|
|
return list.length ? list.map((a) => a.name).join('、') : '暂无数字生命';
|
|
return list.length ? list.map((a) => a.name).join('、') : '暂无数字生命';
|